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
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
Good
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