Skip to content
LogoLogo

VIP-9: service declarations

Status: accepted by Will, 2026-10-28.

Decision

// fragment
// VibeLang
service Users {
  find(id: string): User!NotFound
}
 
// compiles to exactly Effect's class
class Users extends Context.Service<Users, {
  find(id: string): Effect<User, NotFound>
}>()("@acme/app/src/users#Users") {}
  • service Name { members } and service Name: Type { … } declare services. An abstract class is no longer a service.

  • The key. The key is Effect's run-time identity for the service. It is derived from the package, the file and the name. A service can write it explicitly instead, static readonly key = "app/Users", as a migrated service does to keep its key. A duplicate key anywhere in the program is a compile error (EK3001).

  • A default. static readonly defaultValue, of the same type as Context.Reference's defaultValue (() => Shape), makes the service a Context.Reference. Nothing has to provide it, and providing it overrides the default.

    service Flags {
      static readonly defaultValue = () => ({ newCheckout: false })
      newCheckout: boolean
    }
    // Context.Reference<{ newCheckout: boolean }>("…#Flags", { defaultValue: () => ({ newCheckout: false }) })
  • Amended 2026-10-30: service default initializer context. A service's static readonly defaultValue initializer uses the source class-field context, outside enclosing Effect execution and fork/join reservation. try e is EK0501 there; compute an Effect-based factory in the surrounding permitted body first and assign that value. An ordinary join(...) helper call remains ordinary in the initializer. Preserve eager once-only declaration evaluation, source order, bindings/TDZ, cold enclosing bodies and eager throw checks. Creating the factory is distinct from native Reference fallback invocation; emitted options-object syntax grants no source permission or extra runtime wrapper. The factory must still have type () => Shape.

  • Two types, as in Effect. Users is the identifier, the type that appears in R. Users.Shape is the members, the type an implementation has; Effect writes it Context.Service.Shape<typeof Users>.

  • Contracts are written out, because members have no bodies to infer from.

  • Misuse checks. A service is a key, not a class you use, so these are compile errors:

    ErrorCodeWhy
    class X extends Users {}EK3002X would inherit Users' key and silently become the same service
    new Users()EK3003
    impl instanceof UsersEK3004always false, because implementations are plain objects
    service Repo<T> { … }, or a member contract capturing an outer type parameterEK3005specialized shapes would share one key; a parameter belonging to a member-local generic signature is allowed
  • Amended 2026-10-29: any member type. A bodyless service member may declare any return type, Effect or not, as Context.Service allows (the old EK2101 is relaxed). Sent to Will on Telegram 2026-10-29 12:28 under his standing rule 'assume I accept'; no objection by 13:00.

  • Amended 2026-10-29: no derived key outside a package. A service in a file outside any package, or declared inside a function or block, has no derived key: omitting static readonly key there is a compile error with a quick fix that inserts one. Decided by the agent on 2026-10-29 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 54).

  • Amended 2026-10-30: recursive member shapes. Declare the written member shape under a private type alias before constructing the service key. Inside that service's own shape, S.Shape denotes that alias directly, so recursive members do not project through the not-yet-constructed typeof S. Outside the shape it retains Context.Service.Shape<typeof S>. Apply this to both class services and defaulted Context.Reference services, using resolved service identity rather than spelling. The hook is type-only; no runtime namespace or Shape property is created.

  • Amended 2026-10-31: local services. The own-shape hook applies in every admitted declaration scope, a function or block included, and creates no namespace. Outside its own shape, a service declared in a function or block has no S.Shape; write Context.Service.Shape<typeof S>. Decided by the agent under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 181).

  • Amended 2026-10-30: outer generic capture. The generic-service diagnostic also rejects a resolved written Shape contract that depends on a free type parameter from an enclosing scope. Follow aliases, constraints, defaulted type arguments and callable thrown contracts; erased type arguments must not specialize one runtime key. A member's own generic parameters are allowed when their contracts do not depend on an outer parameter. A concrete parameter-independent shape inside a generic function remains allowed, as do unrelated generic value computations.

// compiler-gap: pending VIP-9
service NodeKey { static readonly key = "test/NodeKey"; next?: NodeKey.Shape }
function bad<T>() {
  service Store { static readonly key = "test/Store"; value: T } // expect-error: EK3005; the Shape depends on outer T
  return Store
}
service Fixed { static readonly key = "test/Fixed"; identity<T>(value: T): T }
import { Context, Effect, Layer } from "effect"
type NodeKeyShape = { next?: NodeKeyShape }
class NodeKey extends Context.Service<NodeKey, NodeKeyShape>()("test/NodeKey") {}
type NodeImplementation = Context.Service.Shape<typeof NodeKey>
const implementation: NodeImplementation = { next: {} }
const recursiveShapeExit = Effect.runSyncExit(Effect.provide(
  Effect.gen(function* () { const node = yield* NodeKey; return node.next !== undefined }),
  Layer.succeed(NodeKey, implementation)
)) // Success(true)
type RefShape = { next?: RefShape }
const Ref = Context.Reference<RefShape>("test/Ref", { defaultValue: () => ({}) })
type RefImplementation = Context.Service.Shape<typeof Ref>
const refImplementation: RefImplementation = { next: {} }
const referenceShapeExit = Effect.runSyncExit(Effect.gen(function* () {
  const value = yield* Ref
  return value.next === undefined
})) // Success(true)
  • Amended 2026-10-30, corrected 2026-11-03 under VIP-34’s preservation rule: migration warnings. The transitional compiler reports EK4854 for a safe migration from the retired abstract-class spelling to service, retaining its contract and key, and EK4855 for a preserving migration of the legacy static readonly default = shape to an equivalent native Context.Service class. Retain the original key, eager initialization, cached value identity, field and type reads, mutable shape members and missing-provider failure; refuse when those cannot be preserved. Adopting defaultValue and Context.Reference fallback is an explicit semantic change, not this mechanical repair. An abstract class keeps ordinary TypeScript semantics until explicitly migrated. These warnings retain their emitted compatibility codes until a coordinated compiler/documentation renumber; the existing safe-fix and refusal rules apply.

  • Amended 2026-10-30: incomplete declaration diagnostics. A recognized service declaration missing its required body is EK0906. Every custom service declaration in a native TypeScript ambient source context is unsupported (EK0907), before missing-body recovery for that declaration. This includes explicit declare service and inherited ambientness through nested namespaces, ambient external modules, declare global and native declaration-file contexts wherever the source syntax can otherwise occur. export, a complete body, an explicit key or a default does not escape that ambientness. Determine the context from source semantics, not the declaration's spelling; preserve ordinary native ambient classes/consts, imported real keys and generated .d.ts output. Use the intended runtime declaration or import the real service key; do not synthesize an ambient service or an empty implementation. Ordinary TypeScript ambient class/const declarations keep their rules. These codes label existing rejections; they add no admitted syntax.

History

ProblemKindStatus
Two installed copies of a package shared one service (private brands)legofixed, by the derived key
Users vs Users.Shape confusedlocalfixed (D519)

Declarations drew no review findings after round 4. The call shorthand Users.find(id) is a separate VIP.

Prior art

Effect's Context.Service and Context.Reference; a ZIO service trait; an Angular or NestJS injection token.

Will's words

"yea remove that I don't accept that 2 spellings."

"even though we treat it like a class these safeguards make sense. I acccept static default but name it defaultValue. and the same type as defaultValue in context.reference. With all that and all our changes I accept this VIP"

Footnote: old VibeLang

Old VibeLang is a reference only; nothing in it is part of the language unless a VIP adds it. For implementers, this VIP replaces:

D40's abstract-class services and cached static readonly default. Their preserving migration follows the amendment above; the accepted Reference factory is written defaultValue.

  • Amended 2026-11-01: omitted member types. Bodyless method and property signatures follow TypeScript's interface rules. Under noImplicitAny, read() reports TS7010 and an untyped property reports TS7008; with that option off they have implicit any. An explicit read(): any is valid under strict checking. No separate VibeLang error, inferred Effect contract or Effect-only return restriction applies. The repair is the intended written type.

Amended 2026-11-02 (accepted foldkit-48): In service Name: Type { … }, the annotation supplies the exact whole Shape, including a non-object type. The required body admits only the existing key and defaultValue members; another member is EK0902. Pass the type directly to Context.Service<Name, Type>, or Context.Reference<Type> with a default, preserving its name and modifiers. Name.Shape resolves to that type wherever chapter 005 permits it, without an added helper or namespace. Existing key, default, ambient and generic-capture rules apply. Body-only services are unchanged.