Skip to content

Rule proposal: Structurally empty service shapes make distinct tags type-interchangeable #493

Description

@mattiamanzati

Problem

When a Context tag's service shape is an empty (or member-less) interface/object type, TypeScript's structural typing makes every such shape mutually assignable. Two different tags with empty shapes then satisfy each other's R-channel requirements at the type level, so providing the wrong layer erases the wrong dependency and type-checks cleanly. Flags tag/service declarations (Context.GenericTag, Context.Tag, Effect.Service) whose service shape type has no members and no branding.

Why the compiler is silent / what breaks at runtime: The compiler reports the dependency as satisfied because the empty shapes are interchangeable, but the runtime Context map is keyed by the tag's string key, so yielding the never-actually-provided tag dies with a 'Service not found' defect (or resolves the wrong service).

Both examples below type-check with zero errors against effect@4.0.0-beta.104 (re-verified against it with an isolated per-proposal strict 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: empty-structural-service-shape
// BAD: Both service identifiers are structurally EMPTY interfaces, so
// TypeScript treats them as mutually assignable. Providing the WRONG
// service (Calendar) computes Exclude<Users, Calendar> = never and the
// program type-checks as fully provided — but the runtime Context map is
// keyed by the tag's string key, so yielding Users dies with a
// "Service not found" defect. The linter flags Context.Service keys whose
// shape type has no members and no brand.
import { Context, Effect } from "effect"

interface Users {}
interface Calendar {}

const Users = Context.Service<Users>("Users")
const Calendar = Context.Service<Calendar>("Calendar")

// program requires the Users service
const program: Effect.Effect<void, never, Users> = Effect.flatMap(
  Users,
  () => Effect.void
)

// Providing Calendar — the WRONG service — erases the Users requirement
// at the type level: this compiles cleanly with R = never, yet running it
// would defect because no "Users" entry exists in the Context.
const looksFullyProvided: Effect.Effect<void, never, never> = Effect.provideService(
  program,
  Calendar,
  {}
)

void looksFullyProvided

Good

// RULE: empty-structural-service-shape
// GOOD: Class-style Context.Service keys brand the identifier with the
// literal string key (and the shapes are non-empty), so distinct services
// stay nominally distinct in the R channel: providing the wrong service
// no longer erases the requirement, and the compiler catches the mistake.
import { Context, Effect } from "effect"

interface User {
  readonly id: string
  readonly name: string
}

class Users extends Context.Service<Users, {
  readonly getUser: (id: string) => Effect.Effect<User>
}>()("app/Users") {}

class Calendar extends Context.Service<Calendar, {
  readonly today: Effect.Effect<string>
}>()("app/Calendar") {}

// program requires the Users service
const program: Effect.Effect<User, never, Users> = Effect.flatMap(
  Users,
  (users) => users.getUser("1")
)

// Providing the correct service satisfies the requirement
const satisfied: Effect.Effect<User> = Effect.provideService(program, Users, {
  getUser: (id) => Effect.succeed({ id, name: "Alice" })
})

// Providing the WRONG service no longer erases the requirement:
// Exclude<Users, Calendar> stays Users, so the missing dependency
// remains visible in the R channel instead of defecting at runtime
const stillRequiresUsers: Effect.Effect<User, never, Users> = Effect.provideService(
  program,
  Calendar,
  { today: Effect.succeed("2026-07-29") }
)

void satisfied
void stillRequiresUsers

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

emptyStructuralServiceShape

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