Skip to content

Rule proposal: Providing Service.Default alongside an override for one of its packed dependencies silently uses the production dependency #487

Description

@mattiamanzati

Problem

When a service declares a dependencies array, its generated .Default layer has those dependency layers pre-provided (locally eliminated). If a test composition then provides X.Default together with a different layer for one of those packed tags (e.g. a test implementation), the override never reaches X — the packed default was already provided internally. The rule detects a layer composition where Service.Default appears together with another layer producing a tag that is in that service's dependencies array, and suggests X.DefaultWithoutDependencies.

Why the compiler is silent / what breaks at runtime: Types are fine (Default already eliminated those requirements, so the extra provide is a no-op in the R channel), but at runtime the service keeps using the packed production dependency — a test silently talks to the real user database or SMS provider while the test layer is ignored.

Both examples below type-check with zero errors against effect@4.0.0-beta.104 (re-verified) under the monorepo's strict tsconfig, verified with an isolated per-proposal tsconfig — so the compiler offers no protection here and a diagnostic is the only static safety net.

Bad — compiles cleanly, the rule should flag this

// RULE: default-layer-overridden-dependency-ignored
// BAD: AlertService.Default already has its dependencies (UserService, SmsService)
// provided internally (locally eliminated). Providing UserService.testLayer on top
// type-checks — the R channel is already `never`, so the extra provide is a no-op —
// but at runtime AlertService keeps using the packed production UserService.
import { Context, Effect, Layer } from "effect"

class UserService extends Context.Service<UserService, {
  readonly findPhone: (id: string) => Effect.Effect<string>
}>()("UserService") {
  static Default = Layer.succeed(this, {
    findPhone: (id: string) => Effect.succeed(`prod-phone-${id}`)
  })
  static testLayer = Layer.succeed(this, {
    findPhone: (_id: string) => Effect.succeed("555-0000")
  })
}

class SmsService extends Context.Service<SmsService, {
  readonly send: (phone: string, msg: string) => Effect.Effect<void>
}>()("SmsService") {
  static Default = Layer.succeed(this, {
    send: (_phone: string, _msg: string) => Effect.void
  })
}

class AlertService extends Context.Service<AlertService>()("AlertService", {
  make: Effect.gen(function*() {
    const users = yield* UserService
    const sms = yield* SmsService
    return {
      alert: (userId: string, msg: string) =>
        Effect.flatMap(users.findPhone(userId), (phone) => sms.send(phone, msg))
    }
  })
}) {
  // "Fully wired" default: dependency layers are packed in and no longer
  // visible in the layer's requirements.
  static Default = Layer.effect(this, this.make).pipe(
    Layer.provide([UserService.Default, SmsService.Default])
  )
}

// BAD: compiles cleanly, but UserService.testLayer never reaches AlertService —
// the test silently talks to the real UserService implementation.
const TestLayer = AlertService.Default.pipe(
  Layer.provide(UserService.testLayer)
)

void TestLayer

Good

// RULE: default-layer-overridden-dependency-ignored
// GOOD: compose the test layer from DefaultWithoutDependencies — the variant whose
// requirements are still open — so the provided test implementations are the ones
// AlertService actually receives at runtime.
import { Context, Effect, Layer } from "effect"

class UserService extends Context.Service<UserService, {
  readonly findPhone: (id: string) => Effect.Effect<string>
}>()("UserService") {
  static Default = Layer.succeed(this, {
    findPhone: (id: string) => Effect.succeed(`prod-phone-${id}`)
  })
  static testLayer = Layer.succeed(this, {
    findPhone: (_id: string) => Effect.succeed("555-0000")
  })
}

class SmsService extends Context.Service<SmsService, {
  readonly send: (phone: string, msg: string) => Effect.Effect<void>
}>()("SmsService") {
  static Default = Layer.succeed(this, {
    send: (_phone: string, _msg: string) => Effect.void
  })
  static testLayer = Layer.succeed(this, {
    send: (_phone: string, _msg: string) => Effect.void
  })
}

class AlertService extends Context.Service<AlertService>()("AlertService", {
  make: Effect.gen(function*() {
    const users = yield* UserService
    const sms = yield* SmsService
    return {
      alert: (userId: string, msg: string) =>
        Effect.flatMap(users.findPhone(userId), (phone) => sms.send(phone, msg))
    }
  })
}) {
  // Un-wired variant: requirements (UserService | SmsService) stay open.
  static DefaultWithoutDependencies = Layer.effect(this, this.make)
  static Default = this.DefaultWithoutDependencies.pipe(
    Layer.provide([UserService.Default, SmsService.Default])
  )
}

// GOOD: the overrides genuinely satisfy AlertService's requirements.
const TestLayer = AlertService.DefaultWithoutDependencies.pipe(
  Layer.provide([UserService.testLayer, SmsService.testLayer])
)

void TestLayer

Where this came up

Mined from the Effect Office Hours playlist; deduplicated against all implemented tsgo diagnostics and prior rule-proposal issues.

Proposed rule name

defaultLayerOverriddenDependencyIgnored

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions