The Effect LSP emits effect/leakingRequirements on a service whose methods carry R that should have stayed on the layer. The principle: the service's signature is a contract with its callers. If a method returns Effect<A, E, R>, the R becomes a requirement for every consumer, even if it only matters to one particular implementation.
Two ways it leaks:
- Interface leak: the service's signature declares a method with a non-trivial
R. Consumers are forced to satisfy deps the implementation chose, not deps the contract needs. - Layer leak: a
Layer<Service, E, R>lists requirements the service itself doesn't use.
The fix is the same in both cases: keep implementation-specific R off the service's signature, and put it on the Layer. The signature declares the contract; the Layer wires up the implementation.
Bad
notify drags SentryService into every caller, because this particular implementation captures exceptions through Sentry.
import { Context, Effect, Layer } from "effect"
export interface NotifierShape {
readonly notify: (msg: string) => Effect.Effect<void, never, SentryService>
}
export class NotifierService extends Context.Service<NotifierService, NotifierShape>()(
"@app/NotifierService"
) {}
Good
notify returns Effect<void>, no R. The implementation still uses Sentry (via yield* inside its Effect.gen), and Layer.provide(SentryServiceLive) wires it up. Consumers of NotifierService never see Sentry in their R.
import { Context, Effect, Layer } from "effect"
export interface NotifierShape {
readonly notify: (msg: string) => Effect.Effect<void>
}
export class NotifierService extends Context.Service<NotifierService, NotifierShape>()(
"@app/NotifierService"
) {}
export const NotifierLive = Layer.effect(
NotifierService,
Effect.gen(function*() {
const sentry = yield* SentryService
return {
notify: (msg) =>
Effect.gen(function*() {
yield* sendEmail(msg).pipe(
Effect.catchAll((e) => sentry.captureException(e))
)
})
}
})
).pipe(Layer.provide(SentryServiceLive))
The implementation didn't change. Only the shape did. The .pipe(Layer.provide(SentryServiceLive)) is the same in both cases, which is the right place to declare the dependency.
Why bother
- The consumer's R type only grows when the contract itself demands it.
- A test layer doesn't have to wire up services the contract never asked for.
- You can swap the implementation (e.g., one that logs instead of reporting to Sentry) without changing the signature.
For a bigger app, a separate runtime/services.ts is where you'd Layer.mergeAll(NotifierLive, ...) and then Layer.provide(AppLoggerLive) on the result. The contract stays the same when an internal layer swaps.