Skip to content
LogoLogo

Services and the environment

Sources: VIP-9, VIP-12, VIP-6, VIP-14, VIP-33.

service declarations

Syntax

ServiceDeclaration, whose members are TypeScript Member signatures, a KeyMember and a DefaultMember. The parse is wider than the language: it accepts type parameters and any TypeScript class member, so that the static rules below reject them with their codes. Each member has one parse: a member that parses as a KeyMember, a DefaultMember, a PropertySignature or a MethodSignature is that member; any other member is a rejected ClassElement or TypeScript type member. Members are separated as in an interface: a: number, b: string and a: number; b: string are two property signatures, and a: number b: string on one line is a syntax error, EK0908. A member with a body ends at its }, as in a class, so m() { } n(): void on one line is two members and gets the body rule below. A member's type is parsed greedily, as in TypeScript, so a line break ends a member only after its type is complete. The optional R after !! starts on the same line and never with { (write A!!({ … })), so now(): number!! ends its member at the line break:

VibeLang
// compiler-gap: pending VIP-9
service Auth {
  role:
    | "admin"
    | "member"
  check(token: string): boolean!never
  now(): number!!
  later(): number!!
}

service is a declaration keyword under the one rule of Declaration keywords, as type is in TypeScript: service and the name on one line begin a service; the body's { may follow line breaks, as a class body's may.

service Name { members } and service Name: Type { … } declare services. A recognized service header missing its required body is EK0906. A custom service declaration in any native TypeScript ambient source context is EK0907, ahead of the same declaration's missing-body recovery. This includes an explicit declare service prefix and inherited ambientness in declare namespace, ambient external declare module, declare global, or a native declaration-file context wherever custom source service syntax can otherwise occur. Nested regions and export do not escape inherited ambientness; a complete body, explicit key or defaultValue does not create an ambient service form. Supply the intended runtime declaration or import the actual service key; no ambient service API is introduced. export default service S { } is EK0905; write export { S as default }. An abstract class is not a service. A member is typed as in an interface, because it has no body to infer from, and may have any type, Effect or not, as Context.Service allows. For a method or property, an omitted type follows TypeScript's noImplicitAny: with the option on, read() is TS7010 and an untyped property is TS7008; with it off, they are an any return and an any property. Explicit read(): any is valid under strict checking. VibeLang adds no diagnostic of its own and infers no Effect contract; fix TS7010 by writing the intended return type (VIP-9, amended). For throw tracking a VibeLang service member admits only never; a written @throws or @rethrows is EK0406 (Thrown types). A foreign implementation must satisfy that non-throwing contract.

Meaning

VibeLang
// compiler-gap: pending VIP-9
// src/users.vibe in the package @acme/app
service Users {
  find(id: string): User!NotFound
}
Effect
// fragment
type $Users$Shape = { find(id: string): Effect.Effect<User, NotFound> }
class Users extends Context.Service<Users, $Users$Shape>()("@acme/app/src/users#Users") {}
declare namespace Users { type Shape = $Users$Shape }
  • A written whole shape (VIP-9, amended). In service Name: Type { … } the TypeScript type annotation supplies the exact Shape; the required body admits only the existing key and defaultValue members. Pass the written type directly to Context.Service<Name, Type> (or Context.Reference<Type> for a default), preserving its name and modifiers. This form adds no shape helper and no namespace, so, as for a service inside a function, Name.Shape names that type only inside the declaration's own body; elsewhere Name.Shape is not declared (TS2702 in the twin): write the type, or Context.Service.Shape<typeof Name>. Existing key, default, ambient and generic-capture rules apply.
VibeLang
// compiler-gap: pending VIP-9
import { Context } from "effect";
export type Shape = { readonly value: string };
service Port: Shape { static readonly key = "probe/Port" }
export type Implementation = Context.Service.Shape<typeof Port>;
Effect
import { Context } from "effect";
export type Shape = { readonly value: string };
class Port extends Context.Service<Port, Shape>()("probe/Port") {}
export type Implementation = Context.Service.Shape<typeof Port>;

The annotation is the whole shape, including a non-object type. It does not merge body members or imply inheritance. Body-only services retain their rules and output. The example's declaration output is byte-identical to its Effect TypeScript twin:

import { Context } from "effect";
export type Shape = {
    readonly value: string;
};
declare const Port_base: Context.ServiceClass<Port, "probe/Port", Shape>;
declare class Port extends Port_base {
}
export type Implementation = Context.Service.Shape<typeof Port>;
export {};

The key is Effect's run-time identity for the service. It is derived from the package, the file and the name: the name in the package's package.json, /, the file's package-relative path without its extension, #, the service name. The package is the file's Node package scope, the nearest ancestor directory with a package.json; if that file has no string name, the service has no derived key (EK0903 below). The path's segments are joined with / on every OS, as in an import specifier, and only the final extension is removed: src\users.vibe on Windows gives src/users. A service can write it explicitly instead, as a migrated service does to keep its key:

VibeLang
// compiler-gap: pending VIP-9
service Clock {
  static readonly key = "app/Clock"
  now(): number!never
}
Effect
// fragment
type $Clock$Shape = { now(): Effect.Effect<number> }
class Clock extends Context.Service<Clock, $Clock$Shape>()("app/Clock") {}
declare namespace Clock { type Shape = $Clock$Shape }
  • A default. static readonly defaultValue, of the type of Context.Reference's defaultValue (() => Shape), makes the service a Context.Reference. Nothing has to provide it, and providing it overrides the default. It may carry a type annotation, as key may: static readonly defaultValue: () => Flags.Shape = ….

    VibeLang
    // compiler-gap: pending VIP-9
    service Flags {
      static readonly defaultValue = () => ({ newCheckout: false })
      newCheckout: boolean
    }
    Effect
    // fragment
    type $Flags$Shape = { newCheckout: boolean }
    const Flags = Context.Reference<$Flags$Shape>("@acme/app/src/flags#Flags", { defaultValue: () => ({ newCheckout: false }) })
    declare namespace Flags { type Shape = $Flags$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>, and the compiler declares Users.Shape as a direct alias of the written member object, named by a fresh private type such as $Users$Shape above. It does not extract that alias through its own key while resolving the class base: self- and mutually recursive member types remain ordinary recursive object types. Private helper names are hygienic, including in declaration output; member modifiers and callable throw metadata are retained. A Context.Reference is a value, not a class, and never appears in R, so a service with a defaultValue has Flags.Shape and no identifier type. EK3001 to EK3004 apply to it unchanged: when Flags: is EK3004, as for any service. EK0702 covers only a reference built with Context.Reference(…) directly (chapter 004). A member admits only non-throwing implementations; source tags cannot opt it into throwing (Assignability).

  • Inside a function or block the counterpart is a private named member type and the class (or const), without a namespace, because TypeScript allows a namespace only at the top level of a module or namespace (TS1235). Inside the service's own members Users.Shape names that private type, by VIP-9's type-only hook, so recursive members work. Elsewhere Users.Shape is not declared; write Context.Service.Shape<typeof Users>:

    VibeLang
    // compiler-gap: pending VIP-9
    effect function test() {
      service Users { static readonly key = "test/Users"; find(id: string): User!NotFound }
      const fake: Context.Service.Shape<typeof Users> = { find: (id) => succeed(user) }
    }
    function linked() {
      service Local { static readonly key = "app/Local"; value: number; next?: Local.Shape }
      const impl: Context.Service.Shape<typeof Local> = { value: 9, next: { value: 10 } }
      return impl.next?.value // 10
    }
    Effect
    // fragment
    function test() {
      return Effect.gen(function* () {
        type $Users$Shape = { find(id: string): Effect.Effect<User, NotFound> }
        class Users extends Context.Service<Users, $Users$Shape>()("test/Users") {}
        const fake: Context.Service.Shape<typeof Users> = { find: (id) => Effect.succeed(user) }
      })
    }
    function linked() {
      type $Local$Shape = { value: number; next?: $Local$Shape }
      class Local extends Context.Service<Local, $Local$Shape>()("app/Local") {}
      const impl: Context.Service.Shape<typeof Local> = { value: 9, next: { value: 10 } }
      return impl.next?.value // 10
    }

A service's resolved member contracts cannot capture a type parameter from an enclosing scope (EK3005). Follow aliases, constraints, defaulted type arguments and callable throw metadata by type-parameter identity. A member-local generic parameter is allowed when its contract is independent of outer parameters; a concrete service inside a generic function is also allowed. Explicit keys and duplicate-key checks are unchanged.

VibeLang
// compiler-gap: pending VIP-9
function storeKey<T>() {
  service Store { static readonly key = "test/Store"; value: T } // EK3005
  return Store
}
service Fixed {
  static readonly key = "test/Fixed"
  identity<T>(value: T): T // allowed: one polymorphic contract
}
service Repo {
  static readonly key = "test/Repo"
  value: number; next(): Repo.Shape; peer(): Peer.Shape;
}
service Peer { static readonly key = "test/Peer"; read(): Repo.Shape }
Effect
import { Context, Effect } from "effect"
 
type $Repo$Shape = { value: number; next(): Repo.Shape; peer(): Peer.Shape }
class Repo extends Context.Service<Repo, $Repo$Shape>()("test/Repo") {}
declare namespace Repo { type Shape = $Repo$Shape }
type $Peer$Shape = { read(): Repo.Shape }
class Peer extends Context.Service<Peer, $Peer$Shape>()("test/Peer") {}
declare namespace Peer { type Shape = $Peer$Shape }
const impl: Repo.Shape = { value: 7, next() { return this }, peer() { return peer } }
const peer: Peer.Shape = { read() { return impl } }
type $Fixed$Shape = { identity<T>(value: T): T }
class Fixed extends Context.Service<Fixed, $Fixed$Shape>()("test/Fixed") {}
declare namespace Fixed { type Shape = $Fixed$Shape }
const fixed: Fixed.Shape = { identity(value) { return value } }
const polymorphicShapeExit = Effect.runSyncExit(Effect.sync(() => [fixed.identity(7), fixed.identity("ok")])) // Success([7, "ok"])
const recursiveShapeExit = Effect.runSyncExit(Effect.provideService(Effect.gen(function* () {
  const repo = yield* Repo
  return repo.next().peer().read().value
}), Repo, impl)) // Success(7)

The defaultValue initializer has the source class-field context, including in a service declared inside an Effect body. It is outside the enclosing Effect execution and fork/join reservation context: try e there is EK0501, while join(...) can call an ordinary user helper. A plain factory body has its own plain-function boundary; a nested explicit Effect callable keeps its existing context and must still satisfy () => Shape.

The initializer runs once, eagerly at the declaration's existing execution point, in source order; inside an Effect body that declaration work stays cold until the body runs. Keep source bindings, TDZ, native exceptions and existing eager throw checks. Creating the factory value is distinct from invoking it through native Context.Reference fallback behavior. Lowering into an options object does not grant Effect execution, defer initializer work into the factory, or add a runtime wrapper.

When computing a factory needs an Effect, compute the value in the surrounding Effect body first:

VibeLang
// compiler-gap: pending VIP-9
let initializerRuns = 0; let fallbackCalls = 0
function join(factory: () => { n: number }) { initializerRuns++; return factory }
effect function localDefault() {
  const factory = try succeed(() => { fallbackCalls++; return { n: 1 } })
  service LocalFlags {
    static readonly key = "default-context/LocalFlags"
    static readonly defaultValue = join(factory)
    n: number
  }
  const n = (try LocalFlags).n
  return [initializerRuns, fallbackCalls, n]
}
// Putting try succeed(...) directly in defaultValue is EK0501.
Effect
import { Context, Effect } from "effect"
 
let initializerRuns = 0; let fallbackCalls = 0
function join(factory: () => { n: number }) { initializerRuns++; return factory }
function localDefault() {
  return Effect.gen(function* () {
    const factory = yield* Effect.succeed(() => { fallbackCalls++; return { n: 1 } })
    const LocalFlags = Context.Reference<{ n: number }>("default-context/LocalFlags", {
      defaultValue: join(factory)
    })
    const n = (yield* LocalFlags).n
    return [initializerRuns, fallbackCalls, n]
  })
}
const coldDefault = localDefault()
const beforeDefaultRun = [initializerRuns, fallbackCalls] // [0, 0]
const defaultInitializerExit = Effect.runSyncExit(coldDefault)
console.log(defaultInitializerExit) // Success([1, 1, 1])

A service's Shape is the written member object, so self- and mutually recursive member contracts are allowed. service Name: Type { … } supplies the exact whole Shape: its body permits only key and defaultValue, and the type passes straight to Context.Service<Name, Type>, or to Context.Reference<Type> with a default. Any other body member is EK0902: put it in the supplied shape type. So service Port: Shape { static readonly key = "probe/Port" } means class Port extends Context.Service<Port, Shape>()("probe/Port") {}.

A defaultValue initializer has class-field context even inside an Effect body: try there is EK0501.

Using a service

There is no Users.find(id) shorthand (VIP-10, denied). Get the service with try, then call it:

VibeLang
// compiler-gap: pending VIP-9
effect function load(id: string) {
  const users = try Users
  return try users.find(id)
}
Effect
// fragment
function load(id: string) {
  return Effect.gen(function* () {
    const users = yield* Users
    return yield* users.find(id)
  })
}

Static rules

A service is a key, not a class you use.

Inherited ambient context is a source property, not a check for an own declare token. Ordinary native ambient classes/consts and generated .d.ts counterparts retain TypeScript's rules; import an existing service key when only its type declaration is available. These rejected source forms have no runtime counterpart:

VibeLang
// compiler-gap: pending VIP-9
export {} // the declare-global control is in a module
declare namespace Ambient {
  service Users { static readonly key = "ambient/Users"; n: number } // EK0907
  namespace Nested {
    export service Users { static readonly key = "ambient/Nested"; n: number } // EK0907
  }
  service Defaults { static readonly key = "ambient/Defaults"; static readonly defaultValue = () => ({ n: 1 }); n: number } // EK0907
}
declare global {
  service GlobalUsers { static readonly key = "ambient/Global"; n: number } // EK0907
}
effect function runtimeAlternative() {
  service RuntimeUsers { static readonly key = "ambient/Runtime"; n: number }
  provide RuntimeUsers = { n: 7 } { return (try RuntimeUsers).n }
}

The runtime alternative constructs its real service when the enclosing Effect runs. The ordinary native ambient class below erases; its TypeScript acceptance does not create the runtime key promised by a source service.

Effect
import { Context, Effect, Exit, Layer } from "effect"
 
declare namespace NativeContract {
  class Existing extends Context.Service<Existing, { n: number }>()("ambient/Existing") {}
}
function runtimeAlternative() {
  return Effect.gen(function* () {
    class RuntimeUsers extends Context.Service<RuntimeUsers, { n: number }>()("ambient/Runtime") {}
    return yield* Effect.provide(Effect.gen(function* () {
      return (yield* RuntimeUsers).n
    }), Layer.succeed(RuntimeUsers, { n: 7 }))
  })
}
const ambientContextRepairExit = Effect.runSyncExit(runtimeAlternative())
console.log(ambientContextRepairExit) // Success(7)

A member gets one diagnostic: the first row below that names it. The general member row, the EK0902 row beginning "any member", applies only when no other row applies.

RuleCodeWhy
a recognized service declaration missing its required bodyEK0906recovery does not supply an empty body
a custom service declaration in an ambient source context, explicit or inheritedEK0907a service creates a runtime key; this takes priority over the same missing-body recovery
two members on one line with no separator, a: number b: string; write ; or a line break between themEK0908quick fix: insert ; after the first member; what counts as a Separator is unchanged
two services with the same key anywhere in the programEK3001
class X extends Users {}EK3002X would inherit Users' key and silently become the same service
new Users()EK3003
impl instanceof Users, and a class pattern when Users: or when Users(…):EK3004always false, because implementations are plain objects
service Repo<T> { … }, or a written member contract depending on an outer type parameterEK3005specialized shapes would share one key; use a concrete shape or a member-local generic signature
static key = "a" without readonly, or a key whose initializer is not a string literal (static readonly key = prefix + "S", static readonly key = `S`). A type annotation is allowed, as in TypeScript: static readonly key: string = "S"EK0901EK3001 needs every key at compile time
in service Name: Type { … }, any member other than a KeyMember or a DefaultMemberEK0902the annotation is the whole shape; the body adds no members
any member that is not a PropertySignature, a MethodSignature, a KeyMember or a DefaultMember: a body, an initializer, a modifier (private, protected, #x, declare, accessor, override), a get or set accessor, a constructor, an index signature, a call signature (x: number): string, a construct signature new (): X, or another static. A bodyless effect find(id: string): User!NotFound or async find(): Promise<User> is EK0902; the quick fix removes the modifier: find(id: string): User!NotFoundEK0902as in a TypeScript interface, members declare a shape and implement nothing
service S extends Base { }, service S implements I { }EK0904a service's identity is its key; compose shapes with types (find: Base.Shape["find"])
export default service S { }EK0905export default takes a function, class or expression, as in ECMAScript; write export { S as default }
VibeLang
// compiler-gap: pending VIP-9
class FakeUsers extends Users { }        // EK3002
const u = new Users()                    // EK3003
if (impl instanceof Users) { }           // EK3004
match (impl) { when Users: 1; }          // EK3004: always false
service Admins extends Users { }         // EK0904

A service in a file outside any package (or in one whose package.json has no string name), or declared inside a function or block, has no derived key: omitting static readonly key there is a compile error (EK0903) with a quick fix that inserts one.

VibeLang
// compiler-gap: pending VIP-9
// /repo/tools/x.vibe, no package.json
service Users { }                                      // error: write static readonly key
service Users { static readonly key = "tools/Users" }  // ok
Effect
// fragment
class Users extends Context.Service<Users, {}>()("tools/Users") {}

EK3005 also covers outer type parameters a service's members capture, by resolved identity, through aliases and constraints. A type parameter a member's own generic signature binds is allowed.

provide blocks

Syntax

ProvideStatement, a list of Providers. A top-level comma separates providers. A top-level = whose left side is a LeftHandSideExpression makes Key = value; any other = belongs to the expression, so provide flag ? A : B = x { } provides flag ? A : (B = x). Parentheses make anything else a Layer expression: provide (a, b) { } provides the comma expression's value. The parse never depends on types. Providers are evaluated once, left to right, when the statement runs.

provide is a contextual keyword, and its providers and block are on one line, as with async (VIP-12): ProvideStatement puts [no LineTerminator here] after provide, on both sides of each comma and before the block's {. At a statement start, provide is a keyword when the tokens from provide through that { parse as a valid ProvideStatement head, or the bounded provider-comma recovery below applies; otherwise it is an identifier, in any code. When both readings parse, the provide statement wins, as an arrow head does: provide / a / { } provides the regex /a/, not TypeScript's (provide / a) / {}. Inside a provider, line breaks are free, as in any expression:

VibeLang
// compiler-gap: pending VIP-12
effect function test() {
  provide AppLive, Users = {
    find: (id: string) => fetchUser(id) } {
    try a()
  }
  provide Layer.mergeAll(
    UsersLive,
    DbLive) {
    try a()
  }
}

At a statement start, a head is recovered as provide when it otherwise parses through the body-opening { and only a line break immediately before or after one or more top-level provider-separating commas violates the grammar. Relax only those comma-adjacent restrictions for recognition; keep the no-line-break rule after provide, the ordinary provider grammar and the final provider's same-line {. The recovered statement remains invalid (EK1202), with the existing outside-Effect rule (EK1201) still applicable. This does not reserve provide globally or search ahead across other malformed heads. Calls without that body and expressions after return keep their TypeScript readings.

Only actual provider-list separators use this recovery. Line breaks inside calls, arrays and object expressions remain ordinary expression trivia. The formatter does not introduce separator breaks; compose multiline providers inside parentheses, for example with Layer.mergeAll(...). Invalid recovered heads have no lowering or runtime counterpart.

VibeLang
// compiler-gap: pending VIP-12
effect function badAfter() {
  provide A,
  B { try work() } // EK1202
}
effect function badBefore() {
  provide A
  , B { try work() } // EK1202
}
function ordinary(provide: (x: number) => number) {
  return provide(1) // ordinary expression, no statement head
}

The admitted composition and ordinary identifier readings retain their native behavior:

Effect
import { Effect, Layer } from "effect"
 
const A = Layer.empty, B = Layer.empty
const tupleProvided = Effect.provide(Effect.succeed(7), [A, B])
const composedProvided = Effect.provide(Effect.succeed(7), Layer.mergeAll(
  A,
  B))
function ordinary(provide: (x: number) => number) { return provide(1) }
function callThenBlock(provide: (x: number) => number) {
  const first = provide
  (1)
  let body = 0
  provide(2)
  { body = 3 }
  return [first, body]
}
function element(provide: number[]) { return provide[0] }
function subtract(provide: number) { return provide - 1 }
const providerRecoveryExit = Effect.runSyncExit(Effect.gen(function* () {
  return [
    yield* tupleProvided, yield* composedProvided, ordinary(x => x + 1),
    callThenBlock(x => x + 1), element([9]), subtract(9)
  ]
}))
console.log(providerRecoveryExit) // Success([7, 7, 2, [2, 3], 9, 8])

provide AppLive, with Users = testUsers { on the next line is a syntax error (EK1202): the break follows a comma.

VibeLang
// compiler-gap: pending VIP-12
effect function local() {
  const provide = makeProvider()   // a local named provide
  provide(x)                       // a call: no same-line {
  provide[0] = 1                   // an element assignment
  provide - 1                      // a subtraction
}

Trailing blocks don't exist (VIP-11), so provide(x) { … } can't be read as a call.

The provider is an expression position, so effect { starts an EffectBlock, even when a local binding is named effect. Write provide (effect) { … } to provide that binding. provide effect { … } does not mean that binding followed by the body: the braces belong to the Effect block, leaving no provide body. With another block, provide effect { … } { … } parses with an Effect-block provider; it still fails the ordinary Effect.provide type check because a cold Effect is neither a Layer nor a Context. The parse does not inspect the local binding's type or run the Effect automatically.

VibeLang
// compiler-gap: pending VIP-12
effect function providerName() {
  const effect = Layer.empty
  provide (effect) { }
  // provide effect { }      // does not provide the binding: no separate body
  // provide effect { } { }  // parsed Effect-block provider; not a Layer or Context
  return 1
}
Effect
import { Effect, Layer, Exit } from "effect"
 
function providerName() {
  return Effect.gen(function* () {
    const effect = Layer.empty
    yield* Effect.provide(Effect.void, effect)
    return 1
  })
}
function rejectedEffectProvider() {
  const provider = Effect.gen(function* () {})
  // @ts-expect-error an Effect is not a Layer or Context provider
  return Effect.provide(Effect.void, provider)
}
const providerNameExit = Effect.runSyncExit(providerName())
console.log(providerNameExit) // Success(1)

Static rules

RuleDiagnostic
provide L { } outside effect code. The parser reads it as a provide statement to report this, so the statement provide / a / { } in function f(provide: number, a: number) { provide / a / { } } is EK1201, not TypeScript's division: the provide reading wins in any code. After return, a statement cannot start, so return provide / a / { } is a division. provide(x) without a same-line { stays a callEK1201

VibeLang adds no typing rules of its own: Effect's provide types everything.

Meaning

provide providers { … } is exact sugar for Effect.provide. It is a statement that runs where it stands, and every Effect inside it is still run with try. A provider is a Layer (or, alone, a Context), or Key = value, which means Layer.succeed(Key, value). One provider lowers to Effect.provide(gen, p); two or more lower to Effect.provide(gen, [p1, …, pn]). Effect's overloads then decide what a provider may be: a sole provider may be a Layer or a Context; in a list each is a Layer. Where two providers supply one service, the later wins. As Effect.provide documents in effect 4.1.1, a layer an enclosing provide already built is reused, and a sibling provide builds its own copy. A fiber forked inside the block keeps the block's services after the layer is released, as in Effect.provide; use fork.scoped inside scoped(effect { … }) to tie it to a scope.

VibeLang
// compiler-gap: pending VIP-12
effect function test() {
  provide AppLive { try a() }
  provide AppLive, Users = testUsers {
    try a()
    try b()
  }
}
Effect
// fragment
function test() {
  return Effect.gen(function* () {
    yield* Effect.provide(Effect.gen(function* () { yield* a() }), AppLive)
    yield* Effect.provide(
      Effect.gen(function* () { yield* a(); yield* b() }),
      [AppLive, Layer.succeed(Users, testUsers)]
    )
  })
}

The keyword removes the global of the same name, so Effect's function is written Effect.provide.

Layers are composed with Effect's own Layer API; there is no wire (VIP-24, denied):

VibeLang
// compiler-gap: pending VIP-12
const AppLive = UsersLive.pipe(Layer.provide(DbLive), Layer.provideMerge(ConfigLive))

A provide block is an ordinary block, as in TypeScript: its return, break, continue and declarations mean what they mean in any block, and the lowering keeps them with region bodies. In a producer, yield x inside it is the producer's yield (Producers).

Effect's functions are globals

Every value export of Effect's Effect module is a global variable in a .vibe file: retry, sleep, log, all, race, tryPromise, sync, callback, map, scoped, ensuring, runPromise, and so on. The non-function value exports are globals too; in effect 4.1.1, after the clash rule below, they are Do, TypeId, abortSignal, currentParentSpan, currentSpan, fiber, fiberId, interrupt, never, succeedNone, tracer, txRetry and yieldNow. A local declaration or import shadows one, as with any global, so a local fiber or TypeId silently replaces Effect's. These Effect functions do not inherit the @rethrows contracts of Array.prototype.map, Array.prototype.forEach, Array.prototype.filter or Array.prototype.reduce; a throwing callback needs the explicit wrapper required by Failures. Type exports are not global types: write Effect.Effect<A, E, R>, or A!E!R.

A name that clashes is not a global, and is written Effect.x instead. A name clashes if it is:

  • a reserved word: try, catch, void, let;
  • a JavaScript global: undefined;
  • a VibeLang keyword. In effect 4.1.1 these are match, when, service, provide, uninterruptible and interruptible, so Effect.match keeps its prefix.

When a later VIP adds a keyword with the name of an Effect export, that global is removed.

No language feature wraps TypeScript into an Effect. Effect's own functions do (tryPromise, sync, callback, Effect.try), and their arrows capture locals as in Effect TypeScript.

VibeLang
// compiler-gap: pending VIP-6
effect function lookup(q: string, s: string) {
  const r = try tryPromise((signal) => queryDb(q, { signal }))   // queryDb is from a .ts module
  const n = try sync(() => Date.now())                  // Date.now throws never (Appendix E)
  try sleep("1 second")
  const d = try Effect.try(() => JSON.parse(s))     // allowed: Effect.try turns the throw into a failure
  return [r, n, d]
}
Effect
// fragment
function lookup(q: string, s: string) {
  return Effect.gen(function* () {
    const r = yield* Effect.tryPromise((signal) => queryDb(q, { signal }))
    const n = yield* Effect.sync(() => Date.now())
    yield* Effect.sleep("1 second")
    const d = yield* Effect.try(() => JSON.parse(s))
    return [r, n, d]
  })
}

"vibe": { "effectGlobals": false } in the config turns the globals off (Configuration).

Effect's modules are in scope

Every root export of the core effect package is in scope in a .vibe file without an import. In effect 4.1.1 these are 143 namespaces (Schema, Layer, Stream, Option, Data, Context, Schedule, Duration, …) and six functions (pipe, flow, identity, absurd, cast, hole). Emitted code imports each namespace from effect/<Module> and each function from effect/Function, under a fresh name. A local declaration or import shadows one, as with any global. "vibe": { "effectModules": false } in the config turns this off.

Precedence applies per meaning, as in TypeScript. In a type position, a bare name declared as a type by an ambient global declaration (a TypeScript lib file or @types) keeps that meaning (including the DOM's Console, FileSystem, Headers, Response and Event). In a value or namespace position the table below decides. JavaScript globals that aren't platform-specific keep their meaning; Effect's module of the same name is then written Effect.X through the unshadowed implicit Effect, or imported under another name. Emitted namespace imports use fresh names so they do not shadow those global types.

NameBare name meansEffect's module
Array, String, Number, Boolean, BigInt, Symbol, Function, RegExpJavaScript'sEffect.Array, Effect.String, …
Record, IterableTypeScript's typesEffect.Record, Effect.Iterable
Request, Option, Cache, FileSystem, Scheduler, Console (platform-specific)Effect's modulethe bare name
VibeLang
type HostConsole = Console                    // DOM type
const output = Console.log("hi")             // Effect namespace; unrun Effect
Effect
// fragment
import * as Console$ from "effect/Console"
type HostConsole = Console
const output = Console$.log("hi")

Crypto is the DOM's: effect 4.1.1 has no root module Crypto (VIP-14, amended). Effect's cryptography is the effect/crypto namespaces (Digest, Mac, SecureRandom, …), in scope by rule 3 below.

The prelude preserves each imported signature’s thrown contract. An npm signature without @throws throws unknown, so these non-effectful calls use Effect.try bridges; an actual explicit annotation keeps its ordinary contract (Thrown types).

VibeLang
// compiler-gap: pending VIP-14
effect function preludeExamples() {
  const Port = Schema.Number
  const two = try Effect.try(() => pipe(1, (n: number) => n + 1)) // a root function, from effect/Function
  const first = try Effect.try(() => Effect.Array.head([1, 2]))  // JavaScript's Array keeps the bare name
  const o = try Effect.try(() => Option.some(1)) // Effect's Option: the DOM's Option is platform-specific
  return { Port, two, first, o }
}
Effect
import { Effect } from "effect"
import * as Schema from "effect/Schema"
import * as Array$ from "effect/Array"
import * as Option from "effect/Option"
import { pipe as pipe$ } from "effect/Function"
function preludeExamples() {
  return Effect.gen(function* () {
    const Port = Schema.Number
    const two = yield* Effect.try(() => pipe$(1, (n: number) => n + 1))
    const first = yield* Effect.try(() => Array$.head([1, 2]))
    const o = yield* Effect.try(() => Option.some(1))
    return { Port, two, first, o }
  })
}

Where Effect is the compiler's implicit binding, Effect.M, where M is a module in the table's first two rows, is that module: the emitted code imports it under a fresh name, as Array$ above. Every other Effect.x is the export x of effect/Effect. A written binding keeps its ordinary TypeScript meaning: an imported, aliased or local Effect shadows the implicit binding. The namespace from import { Effect } from "effect" has no Array, so Effect.Array there is TS2339; import Array as Arr from effect instead. With vibe.effectModules: false the implicit binding is absent, and explicitly importing Effect does not restore its collision-module qualifiers (VIP-14, amended).

The platform standard library

The restrictions below apply to VibeLang code. A named top-level module body is TypeScript and follows the same rules as a sibling .ts file (VIP-40).

  1. Platform access is only through injected Effect services (FileSystem, Path, ChildProcess, HttpClient, …), each provided by a per-platform layer. Outside a TypeScript module body, a .vibe file has no node:* value imports, no Node globals (process, Buffer) and no platform calls such as fetch, anywhere: in plain and async functions, and inside Effect.try and tryPromise arrows too (VIP-33, amended). A wrapper changes how a failure is represented, not whether the platform may be called. Code that needs the platform lives in a .ts file or a module body (VIP-40).

  2. What Effect lacks goes under Experimental.X, named after the Effect module it extends, and is written so it can be proposed upstream. Nothing that Effect already provides is reimplemented.

  3. Subpath namespaces are in scope: the namespaces exported by the 23 lowercase subpaths in effect 4.1.1's package exports: effect/ai, effect/cli, effect/cluster, effect/crypto, effect/devtools, effect/encoding, effect/eventlog, effect/graphql, effect/http, effect/http-api, effect/jose, effect/net, effect/observability, effect/persistence, effect/process, effect/reactivity, effect/rpc, effect/schema, effect/socket, effect/sql, effect/testing, effect/workers and effect/workflow (HttpClient comes from effect/http, ChildProcess from effect/process). The capitalized effect/<Module> subpaths are the root modules above. These namespaces come in scope with the precedence above, so a subpath namespace whose name is a web platform global names Effect's. In effect 4.1.1 these are Headers (effect/http), Response (effect/ai, not an HTTP response), Event (effect/eventlog) and Worker (effect/workers). KeyFormat (effect/crypto) and Transferable (effect/workers) are DOM types only, so a bare type position keeps the DOM's meaning and Transferable.X names Effect's module, as for Console. FileSystem and Path are root modules. Emitted code imports each namespace from its subpath.

    A name that two subpaths export, or a subpath and the root, is not in scope bare: using it is a compile error (EK1401) that names every exporter and offers one import per exporter. In effect 4.1.1 this covers Model (effect/ai, effect/schema), Prompt (effect/ai, effect/cli) and Utils (effect, effect/rpc). Model.make(…) is that error; import { Model } from "effect/ai" fixes it.

VibeLang
// compiler-gap: pending VIP-33
effect function readConfig(path: string) {
  const fs = try FileSystem.FileSystem               // the module FileSystem exports the service key FileSystem
  return try fs.readFileString(path)
}
import { NodeServices } from "@effect/platform-node"   // a platform package: imported, not in scope
effect function main() {
  provide NodeServices.layer { try readConfig("app.json") }
}
effect function page(url: string) {
  return try tryPromise(() => fetch(url))            // error: a platform call, even inside tryPromise
}
Effect
// fragment
import * as FileSystem from "effect/FileSystem"
function readConfig(path: string) {
  return Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    return yield* fs.readFileString(path)
  })
}

A module is a namespace, not a service key (VIP-33, corrected). try FileSystem runs the namespace, which is not an Effect, so it is the error for try on a value that is not an Effect, as yield* FileSystem is wrong in Effect TypeScript. There is no Path.join(…) shorthand for a service member; write (try Path.Path).join(…). The entry point provides nothing automatically (Functions).

RuleDiagnostic
in a .vibe file, an import or export … from declaration, or an import(…) call with a string-literal specifier, whose specifier names a Node builtin with or without node: (node:fs, fs, child_process: Node's module.builtinModules), except an import type or export type … from declarationEK4402
a statically named value that reaches the platform (rule below) anywhere in a .vibe file, including inside Effect.try and tryPromise arrows. The quick fix and the codemod move the code into a sibling .ts module that does not exist yet; never rewrite an existing foreign file, and refuse with the not-fixed explanation if the move cannot preserve run phase, captures, receiver and public types, or point to the Effect service (HttpClient for fetch)EK4402

EK4402 asks one question of a statically named value: does the symbol it resolves to reach the platform, by doing I/O, starting a timer, or reading or changing host state? Statically named means an identifier, .name, ?.name, ["name"], a template with no substitutions, or a destructured property with such a key, and an alias keeps the symbol it copies. Two kinds of symbol reach the platform:

  • a global binding declared by a host library and by no lib.es* or lib.decorators* file, and not on the allowlist (below);
  • a member of an allowlisted global that reaches the host: AbortSignal.timeout, URL.createObjectURL and URL.revokeObjectURL.

So a statically named member of the standard globalThis whose name is banned is EK4402, including globalThis.fetch, globalThis["fetch"], globalThis.process and globalThis["setTimeout"]. A property on an unrelated object, such as client.fetch or client.timeout, is a different symbol. window.fetch is already rejected because window itself is banned. Only value positions are EK4402: a type query such as type Timer = ReturnType<typeof setTimeout> is erased and allowed, as import type is. A key that is not statically known stays unchecked; this is not a sandbox.

VibeLang
// compiler-gap: pending VIP-33
globalThis.fetch(url)                         // EK4402: use HttpClient
globalThis["setTimeout"](f, 10)               // EK4402: use sleep
AbortSignal.timeout(100)                      // EK4402: it starts a timer; use Effect.timeout
AbortSignal["timeout"](100)                   // EK4402: the same symbol
const { timeout } = AbortSignal               // EK4402: the same symbol
const Signals = AbortSignal
Signals.timeout(100)                          // EK4402: an alias keeps the symbol
URL.createObjectURL(blob)                     // EK4402: it stores the Blob in the host
AbortSignal.any([a, b])                       // ok: pure
AbortSignal[key]                              // unchecked: not statically named
new URL(s).searchParams.get("q")              // ok: pure
client.timeout(100)                           // ok: a different symbol

globalThis may appear only as the object of a statically named member access; any other use, such as a computed key (globalThis[k]), an alias (const g = globalThis) or destructuring (const { fetch } = globalThis), is EK0107 (Legacy JavaScript). A computed specifier (import(name), import("node:" + m)) compiles unchecked; a data: or blob: specifier is EK0107.

import type { Stats } from "node:fs" is allowed because it is erased. import { type Stats } from "node:fs" is EK4402, because verbatimModuleSyntax keeps it as import {} from "node:fs"; the quick fix rewrites it to import type.

The banned globals ("All web apis and all node.js apis should be wrapped and effectful", VIP-33) are derived by resolved binding, not listed (VIP-33, amended). A global is banned when the symbol has a declaration in lib.dom*.d.ts, lib.webworker*.d.ts, lib.scripthost.d.ts or @types/node, none in lib.es*.d.ts or lib.decorators*.d.ts, and is not on the allowlist below. A local binding, an import or an Effect module in scope (Option, FileSystem, Headers, Worker) shadows the global first, and a project's own declare var fetch is not a host declaration. A name that resolves to nothing is TypeScript's TS2304: with no host library in the program, fetch is TS2304, not EK4402. The compiler reads the declarations themselves. platform-globals.json records the result for the pinned TypeScript libs and @types/node: the banned globals, the allowlist, and every member of an allowlisted global, classified. node docs/tools/platform-globals.mjs --write generates it, and a test fails when the file is stale, when a member is unclassified, or when this page and the file disagree. The banned set includes these names, which keep their quick fixes:

  • Node: process, Buffer, global, __dirname, __filename, require, module, exports, setImmediate, clearImmediate;
  • web I/O and host state: fetch, XMLHttpRequest, WebSocket, EventSource, localStorage, sessionStorage, indexedDB, caches, navigator, location, document, window, self, Worker, SharedWorker, BroadcastChannel, postMessage, alert, confirm, prompt, crypto, performance;
  • console and timers: console, setTimeout, setInterval, clearTimeout, clearInterval, queueMicrotask.

It also includes every other host global, such as history, requestAnimationFrame, FileReader, WebAssembly, and window properties such as name, status and open, so a stray name that would read window.name is an error.

VibeLang
// compiler-gap: pending VIP-33
history.pushState({}, "", "/x")       // EK4402: move to a .ts file or wrap it in a service
requestAnimationFrame(draw)           // EK4402
const r = new FileReader()            // EK4402: use FileSystem
signal instanceof AbortSignal         // ok: allowlisted
const name = "Ada"; name.length       // ok: the local shadows window.name
Effect
// fragment
const program = Effect.gen(function* () {
  const fs = yield* FileSystem.FileSystem
  const text = yield* fs.readFileString("data.txt")                // instead of FileReader
  yield* Effect.sleep("16 millis")                                 // instead of a timer
  return yield* Effect.tryPromise((signal) => fetchThing(signal))  // the AbortSignal Effect passes
})

import.meta may appear only as the object of .url. Every other member (dirname, filename, env, main, resolve, host additions such as Vite's hot) and every other use of import.meta (an alias, destructuring, a computed key, an argument, a spread) is EK4402. A type position (typeof import.meta) is TypeScript's.

VibeLang
// compiler-gap: pending VIP-33
const u = new URL("./data.json", import.meta.url)   // ok: module identity (ECMA-262)
const d = import.meta.dirname                        // EK4402: use (try Path.Path).fromFileUrl(…), then dirname
const k = import.meta.env.API_KEY                    // EK4402: use Config.String("API_KEY")
const m = import.meta                                // EK4402: alias
Effect
// fragment
const program = Effect.gen(function* () {
  const path = yield* Path.Path
  const d = path.dirname(yield* path.fromFileUrl(new URL(import.meta.url)))
  const k = yield* Config.String("API_KEY")
})

Bare Worker is Effect's effect/workers namespace, by the precedence above, as Headers, Response and Event are. The ban on Worker covers the host binding only: globalThis.Worker, globalThis["Worker"] and self.Worker are EK4402. A local or imported Worker shadows as usual, and new Worker(url) on the namespace gets TypeScript's own "not constructable" error. An explicit import names the same namespace:

VibeLang
// compiler-gap: pending VIP-33
import { Worker as EffectWorker } from "effect/workers"
const workerSpawner = EffectWorker.Spawner
Effect
import { Worker as EffectWorker } from "effect/workers"
const workerSpawner = EffectWorker.Spawner

ECMAScript library globals (Date.now, Math.random, JSON, Intl) and web data utilities with no I/O (URL, URLSearchParams, TextEncoder, TextDecoder, structuredClone, atob, btoa, AbortController, AbortSignal) stay allowed, apart from the three members that reach the host. The web-utility allowlist is closed: a name can be added later, never removed. Intl is an ECMAScript internationalization library declaration and needs no allowlist entry.

VibeLang
// compiler-gap: pending VIP-33
console.log("hi")            // EK4402: write try Console.log("hi")
setTimeout(f, 10)            // EK4402: write try sleep("10 millis"), then f()
const u = new URL(s)         // ok
VibeLang
// compiler-gap: pending VIP-33
import { readFileSync } from "fs"         // EK4402: same builtin as node:fs
import "node:fs/promises"                 // EK4402: loads the module
export { statSync } from "node:fs"        // EK4402
const cp = import("node:child_process")   // EK4402
import type { Stats } from "node:fs"      // ok: erased
const m = import(name)                    // ok: a computed specifier is not checked
Effect
// fragment
yield* Console.log("hi")
yield* Effect.sleep("10 millis")