Skip to content

Rule proposal: Tag.use/useSync accessors leak the service into the produced effect's requirements #494

Description

@mattiamanzati

Problem

Opt-in rule explicitly requested by the maintainers on stream: flags calls to the v4 tag accessor helpers use and useSync. Calling an accessor immediately adds the service to the requirements of the produced effect, so when used inside another service's method implementation, that service's public shape silently absorbs the dependency. The recommended idiom is to yield the service once at construction and call methods on the resolved instance.

Why the compiler is silent / what breaks at runtime: Everything type-checks, but the service's method type now carries the transitive dependency, so every consumer inherits a requirement that describes the implementation rather than the contract — 'the shape of the service should be just what you do and not what it requires to do it'. The team said outright: 'we could probably add an error rule in the LSP. You can opt in and say no use and no use sync.'

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: no-service-use-accessor
// BAD: calling the tag accessor helpers `use` / `useSync` inside another
// service's method implementation immediately adds Notifications to the R of
// the produced effect, so Mailer's inferred shape silently absorbs the
// dependency — every consumer of Mailer.send now inherits Notifications, a
// requirement that describes the implementation instead of the contract.
// The linter would flag `Notifications.use(...)` and `Notifications.useSync(...)`.
import { Context, Effect, Layer } from "effect"

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

class Mailer extends Context.Service<Mailer>()("Mailer", {
  make: Effect.gen(function*() {
    return {
      // BAD: accessor call — R of `send` is now `Notifications`
      send: (msg: string) => Notifications.use((n) => n.notify(msg)),
      // BAD: the sync accessor variant leaks in exactly the same way
      channelName: () => Notifications.useSync((_n) => "default-channel")
    }
  })
}) {
  static Default = Layer.effect(this, this.make)
}

// The leak made visible: the caller only asked for Mailer, yet its
// requirements must also mention Notifications to type-check.
const program: Effect.Effect<void, never, Mailer | Notifications> = Effect.gen(function*() {
  const mailer = yield* Mailer
  yield* mailer.send("hello")
})

void program

Good

// RULE: no-service-use-accessor
// GOOD: yield the dependency once at construction time and call methods on the
// resolved instance. The dependency is settled when the Mailer layer is built,
// so the methods' R stays clean — the service's shape is "just what you do,
// not what it requires to do it". Consumers depend only on Mailer.
import { Context, Effect, Layer } from "effect"

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

class Mailer extends Context.Service<Mailer>()("Mailer", {
  make: Effect.gen(function*() {
    // GOOD: dependency captured once at construction
    const notifications = yield* Notifications
    return {
      // R of `send` stays never — no leak into the public shape
      send: (msg: string) => notifications.notify(msg),
      channelName: () => Effect.succeed("default-channel")
    }
  })
}) {
  // The requirement lives in the layer graph, where it belongs.
  static DefaultWithoutDependencies = Layer.effect(this, this.make)
  static Default = this.DefaultWithoutDependencies.pipe(
    Layer.provide(Notifications.Default)
  )
}

// Consumers now only require Mailer — the contract, not the implementation.
const program: Effect.Effect<void, never, Mailer> = Effect.gen(function*() {
  const mailer = yield* Mailer
  yield* mailer.send("hello")
})

void program

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

noServiceUseAccessor

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