--- name: effect-v4-idioms description: Use when writing core Effect v4 code — generators (Effect.gen/Effect.fn), typed error handling and recovery (catch/catchTag/catchFilter/catchReason), yieldable errors, PlatformError on FileSystem/Path IO, Cause inspection, Scope and resource cleanup, forking and fibers, runtime/entrypoints, FiberRef-as-Context.Reference, structural equality, Config.schema inputs (a JSON-string input is Config.schema(Schema.fromJsonString(S)), and what withDefault does and does not swallow), and polling with Effect.repeat options instead of a recursive Effect.sleep. Teaches the idiomatic v4 spelling. --- # Effect v4 core idioms The idiomatic way to write core v4 code — generators, errors, resources, fibers, runtime, equality. For *which module* to reach for in the first place (what is `Sink`, `RcMap`, `Latch`…), consult `effect-v4-module-index` — this skill owns patterns, not the map. For confirming that a name exists before you rely on it, see `effect-v4-source-lookup`. Every identifier below was verified to exist, and every `file:line` citation checked against the vendored tree; when you reach past this list, run one runtime probe (`node --input-type=module -e "import * as Effect from 'effect/Effect'; console.log(typeof Effect.X)"`) before writing — v4 is a ground-up redesign and muscle memory lies. ## Generators — `Effect.gen` for workflows `Effect.gen(function*() { ... })` is unchanged for the common case. Reach for it when a block *orchestrates*: multiple `yield*`, branching, reading several services, implementing a layer or handler. The one change bites service methods: a generator that needs `this` no longer takes the bare `this` argument. Pass it in an options object: ```ts class Counter { readonly step = 1 // v4: self lives in an options object, NOT Effect.gen(this, fn) next = Effect.gen({ self: this }, function* () { return yield* Effect.succeed(this.step + 1) }) } ``` ## `Effect.fn` for reusable operations A parameterized operation you call from several places is `Effect.fn`, not an inline `Effect.gen`: ```ts const loadUser = Effect.fn("loadUser")(function* (id: string) { const row = yield* db.query(id) return decode(row) }) ``` - `Effect.fn("name")(function* …)` — named span **and** clean stack frames; use at public/business operation boundaries so traces and errors carry the name. - `Effect.fn(function* …)` — bare, no name: still gets the stack-frame ergonomics without minting a named span. Use inside a module where the span would be noise. - `Effect.fnUntraced(function* …)` — escape hatch only (low-level internals, or a deliberate tracing trade-off). Not a default. Split rule: reusable parameterized operation → `Effect.fn`; one-off inline workflow → `Effect.gen`. **`Effect.fn` is not generator-only.** A plain function that *returns* an Effect both runs and typechecks: ```ts const double = Effect.fn("double")((n: number) => Effect.succeed(n * 2)) ``` Reach for it when the body has nothing to `yield*` — you still get the named span and the stack frames, without a generator wrapping a single expression. The generator is the common case, not a requirement. Two semantics of the plain-function form worth stating: the body is **lazy** — it runs when the returned effect is *executed*, not when `double(21)` is called (calling the wrapped function only constructs the effect); and a **`throw` from the body becomes a `Die` defect**, not a typed failure — the plain form gives you no typed error channel for free, so a body that can throw belongs in `Effect.try`, or the throw escapes as a defect. ## Wrapping a callback API — `Effect.callback` Wrapping a callback API is **`Effect.callback`**. There is no `Effect.async` — the name resolves to `undefined`, so reaching for it fails at the call site with a "not a function" that points nowhere near the cause. **Resuming runs the fiber synchronously, on the caller's stack.** Once the effect has suspended, `resume(effect)` evaluates the fiber right there (the `Async` primitive's `fiber.evaluate`, `callbackOptions` in `internal/effect.ts`), so everything after the `yield*` runs *inside* whatever called `resume` — React's commit phase, an event emitter's dispatch loop, a stream's `data` handler — before that call returns. When the caller is mid-operation and must finish first (React has not yet reported a frame that threw; an emitter is still iterating its listeners), defer the resume: `queueMicrotask(() => resume(Effect.void))`. ## Error handling — `catch*` recovery The recovery family is spelled `catch*`, with `Filter` and `Reason` variants. The idiomatic recoveries: - **`Effect.catchTag(tag, handler)` / `Effect.catchTags({ Tag: handler })`** — targeted typed recovery, both unchanged. Default choice for domain errors. `catchTag` also accepts a non-empty tag ARRAY sharing one handler — `Effect.catchTag(["UnknownRefError", "GitCommandError"], () => fallback)` — which obviates `catchTags` boilerplate when several tags route to the same recovery (`Effect.ts:2744` `Arr.NonEmptyReadonlyArray>`). - **`Effect.catch(handler)`** — recover from any typed failure; there is no `catchAll`. **`Effect.catchCause(handler)`** for full-cause infra handling, **`Effect.catchDefect(handler)`** for defects. - **`Effect.match({ onFailure, onSuccess })`** — totalize an effect to a plain value when you want no failure channel left. Selective recovery now takes a `Filter`, not an `Option`-returning predicate: ```ts import { Effect, Filter } from "effect" Effect.fail(42).pipe( Effect.catchFilter( Filter.fromPredicate((e: number) => e === 42), () => Effect.succeed("caught") ) ) ``` Use `Effect.catchCauseFilter` for the cause-level equivalent. **`Effect.catch` recovers typed failures ONLY — defects and interrupts pass straight through.** `Effect.fail("x").pipe(Effect.catch(h))` succeeds with the handler's value, while the same pipe on `Effect.die` and `Effect.interrupt` exits `Failure` with the `Die`/`Interrupt` reason intact. The corollary bites in code whose error channel is later declared `never`: a bare `JSON.parse` (or any throwing host call) inside such a function is a **defect**, so no downstream `Effect.catch` will absorb it — it escapes through the `never` channel. Wrap the throwing call locally (`try/catch` or `Effect.try`) at the point it can throw; do not assume a catch further out has you covered. ## `PlatformError` — the error type of core IO Core `FileSystem` / `Path` operations fail with `PlatformError`, and its shape is not guessable: `effect` re-exports the module **as a namespace** (`export * as PlatformError from "./PlatformError.ts"`, index.ts:407) and the error **class** is declared inside it (PlatformError.ts:157, a `Data.TaggedError("PlatformError")`). So the type you write is the doubled `PlatformError.PlatformError`: ```ts import type { PlatformError } from "effect"; import { Effect, FileSystem, Path } from "effect"; const isGitRoot = ( dir: string, ): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* fs.exists(path.join(dir, ".git")); }); ``` Written once it looks like a typo — which is exactly why it gets replaced with `unknown`. Do not: typing a `FileSystem`-backed channel `unknown` violates the house standard (never collapse errors to `string`/`unknown` early) when the precise type is one `import type` away. `fs.exists: (path: string) => Effect.Effect` (FileSystem.ts:143). The `reason` field is where the detail lives: a `PlatformError` wraps a `BadArgument` (rejected caller input) or a `SystemError` (a host failure, carrying a normalized `SystemErrorTag`), `PlatformError.ts:36,109,157`. **`fs.exists` already absorbs `NotFound` — anything else is a real failure.** Core derives `exists` from `access` in `FileSystem.make` (`FileSystem.ts:499`): `access(path)` mapped to `true`, with a `PlatformError` whose `reason._tag` is `"NotFound"` mapped to `false` and **every other reason re-failed** (`EACCES`, `ENOTDIR`, `EIO`). So `fs.exists(p)` answers the question "is it there?" and refuses to answer "may I see it?". Appending `Effect.orElseSucceed(() => false)` therefore does not fix a missing case — it is a **policy choice** to report an unreadable path as absent, and it should be written and reviewed as one (a config *discovery* walk may want it; a "write here" check does not). The trap this replaces: adding the fallback because an in-memory test filesystem seemed to need it — `@effected/memfs` answers an unseeded path with `NotFound`, which `exists` already maps to `false` without help. **Construct one through the module's factories, never `new`.** `new FileSystem.SystemError(...)` fails with "is not a constructor" — the constructors are `PlatformError.systemError({...})` and `PlatformError.badArgument({...})`. Recover a nested `reason` without stripping the parent error from the channel (e.g. an `AiError` whose `reason` is a `RateLimitError`): ```ts someAiCall.pipe( Effect.catchReason("AiError", "RateLimitError", (reason) => Effect.sleep(reason.retryAfter) ) ) ``` `Effect.catchReasons("AiError", { RateLimitError: h1, AuthError: h2 })` handles several reason tags at once; `Effect.catchEager(handler)` is the optimization variant of `catch` that runs synchronous recovery immediately. ## Yieldable — not everything is an Effect Not every type is yieldable in v4, and `Option`/`Result` are narrower than the vendored notes claim. **The notes are the trap here.** `migration/yieldable.md` documents a `Yieldable` trait whose contract is `asEffect(): Effect`, lists `Option` and `Result` as implementors, and states "the runtime calls `.asEffect()` internally when yielding". **The `Option`/`Result` claim does not hold**: neither implements `asEffect`, and `typeof Option.some(1).asEffect` is `undefined`. `asEffect` itself does exist in core (`Effectable.ts` — an opt-in base class and mixin a domain type can extend to become yieldable), but `Option` and `Result` are not built on it. Rung 1 is prescriptive and it has gone stale on this specific claim; the source settles it. Directly yieldable — **all five rows probed**, against two controls: a positive `Effect.succeed` that yielded, and a discriminating `Option.some(7)` that died, proving the harness could observe a failure to yield at all: | Value | `yield*` inside `Effect.gen` | | --- | --- | | `Effect` | works | | `Config` | works — it **is** an `Effect` (`Config extends Effect`, `Config.ts:108`) | | any `Context.Service` | works | | `Option` | **dies as a defect** | | `Result` | **dies as a defect** | **`Option` and `Result` are both off the list, and both die the same way.** `yield* Option.some(7)` exits `Failure` with `Die("Fiber.runLoop: Not a valid effect: some(7)")`; `Option.none()`, `Result.succeed(42)` and `Result.fail("boom")` die identically: `yield* Result.succeed(42)` dies `"Fiber.runLoop: Not a valid effect: success(42)"`, against an `Effect.succeed` control that returned normally. Note the **success** cases die too: this is not "errors need a bridge", it is "these are not Effects at all". Their `[Symbol.iterator]` yields the value itself, and the fiber loop rejects anything carrying no `evaluate` (`internal/effect.ts:671`). The bridge is a module function on `Effect`, one per type: ```ts const u = yield* Effect.fromOption(maybeUser) // Effect.ts:1867 — fails NoSuchElementError const v = yield* Effect.fromResult(Fmt.parseResult(text)) // Effect.ts:1828 — fails typed with the Result's E ``` `Effect.fromOption` takes an optional second argument for the failure (`Effect.fromOption(maybeUser, () => new UserMissing())`), so the `NoSuchElementError` default is not something you have to live with. To read a `Result` **outside** an Effect, narrow with `Result.isSuccess` / `Result.isFailure`, then read the value off **`.success` / `.failure`** — not `.value`: ```ts const r = Fmt.parseResult(text) if (Result.isSuccess(r)) use(r.success) // NOT r.value — undefined, silently else report(r.failure) // NOT r.left / r.right — there is no Either ``` The `Success` variant carries `.success` and the `Failure` variant `.failure` (`Result.ts:161,99`); there is no `.value`, and no `.right` / `.left`. A read through `.value` compiles against `unknown` in a loose context and returns `undefined` at runtime with no error — the exact silent miss that cost a round-4 consumer a debugging cycle. No longer yieldable — call the module function: ```ts const n = yield* Ref.get(ref) // NOT yield* ref const v = yield* Deferred.await(deferred) // NOT yield* deferred const r = yield* Fiber.join(fiber) // NOT yield* fiber (Fiber is not an Effect) ``` To hand an `Option` or a `Result` to a data-first combinator, go through the same bridge — or just stay in a generator: ```ts Effect.map(Effect.fromOption(Option.some(42)), (n) => n + 1) ``` **`Config` needs no bridge — it already IS an `Effect`.** `Config` extends `Effect` (`Config.ts:108`), so it pipes straight into the combinators, and there is no `Effect.fromConfig` to reach for: ```ts Config.String("PORT").pipe(Effect.catchTag("ConfigError", () => Effect.succeed("8080"))) ``` That `Config` sits beside `Option` in every "yieldable" list ever written — and yet needs the opposite treatment — is exactly what makes the pair a trap: the one that looks like it needs converting does not, and the one that looks interchangeable with it dies. Two more `Config` facts worth carrying: - **`ConfigError` is not on the `effect` root.** It is `Config.ConfigError`; importing it from `"effect"` yields `undefined`, and a `catchTag` against that silently never matches. - **`Config.option` still carries a `ConfigError`.** It turns a *missing key* into `Option.none()`, but a **provider-source failure survives** — a present, unparseable value still fails. It is not `Effect, never>`, and a test that only exercises the absent-key path will "prove" that it is. - **A structured input is `Config.schema(codec, "NAME")` (`Config.ts:877`), and a JSON-string input is `Config.schema(Schema.fromJsonString(S), "NAME")`** — not `Config.String` + `JSON.parse` + `decodeUnknown*`, which re-derives the codec by hand and puts a throwing host call in the seam. `withDefault` covers **absent data only**: it replaces an `Absent` resolution and leaves every other error in the channel (`Config.ts:528`). Probed under `ConfigProvider.fromEnv` with `Config.schema(Schema.fromJsonString(Struct({ a: Number })), "INPUT_PAYLOAD") .pipe(Config.withDefault({ a: -1 }))`: an unset variable **and `""`** both resolve to the default; `"{nope"` and `'{"a":"str"}'` both fail with a typed `ConfigError` the default does **not** swallow. The `""` case is the provider's doing, not the schema's — `fromEnv` / `fromEnvRecord` / `fromUnknown` map an empty string to *missing* unless `{ preserveEmptyStrings: true }` (`ConfigProvider.ts:781`), and with that flag set the same `""` is present-but-malformed and fails. That is exactly the contract a GitHub Actions input needs — the runner exports an unset input as `INPUT_NAME=""` — and a hand-rolled parse has to re-invent it. ## Yieldable errors — schema-backed error classes Define errors as `Schema.TaggedError`. Naming trap: `Schema.TaggedErrorClass` is `undefined` — the current name is `Schema.TaggedError`, same curried call shape; code using the old name fails with "TaggedErrorClass is not a function". The payoff at the call site: an instance is yieldable — `yield* new MyError({...})` fails the effect — and it is `instanceof Error`. Capture unknown throwables with a `Schema.Defect()` field — `Schema.Defect` is a **callable**, not a bare schema value. The bare `cause: Schema.Defect` typechecks but throws at construction (`Cannot read properties of undefined (reading 'encoding')`); you must call it: ```ts class ParseError extends Schema.TaggedError()("ParseError", { cause: Schema.Defect() // NOT Schema.Defect — the bare form throws when constructed }) {} Effect.try({ try: () => JSON.parse(input), catch: (cause) => ParseError.make({ cause }) }) ``` `cause: Schema.Defect()` is **only** for wrapping an *unknown throwable* you caught. It is the wrong tool for a **synthetic domain error** you raise yourself from structured data (e.g. a navigation mismatch carrying `expected`/`depth`, or a validation failure with a known shape). There, the fix for a `reason: string` that flattens discriminating data is to **promote that data to typed fields** and keep `reason` as the human `message` — not to add a `Defect`. Rule of thumb: `Defect` captures a *foreign* failure; typed fields describe a *known* one. Failing through this typed channel — never letting a `throw` escape as an unhandled defect — is the invariant `hardening-a-parser-port` enforces. ## Cause — a flat array of reasons v4 replaces the recursive `Cause` tree with a flat wrapper over an array. There are only three reason variants: ```ts interface Cause { readonly reasons: ReadonlyArray> } // Reason = Fail | Die | Interrupt ; empty reasons array = the empty cause ``` Inspect it by iterating `cause.reasons` and switching on `reason._tag`, or with the reason-level guards and cause-level predicates: ```ts const failures = cause.reasons.filter(Cause.isFailReason) if (Cause.hasInterrupts(cause)) { /* was interrupted */ } ``` - Reason guards: `Cause.isFailReason` / `isDieReason` / `isInterruptReason`. - Cause predicates: `Cause.hasFails` / `hasDies` / `hasInterrupts`. - Extraction returns `Result` or `Option`: `Cause.findError` (Result), `Cause.findErrorOption` (Option), `Cause.findDefect`. - Merge causes with `Cause.combine` — the sequential/parallel distinction is gone (it concatenates reasons). The idiom to internalize is *iterate `reasons`, switch on `_tag`*. ### Probing `catchCause` — the obvious experiment lies `Effect.catchCause`'s handler **does not run** when a fiber suspended in `Effect.never` is interrupted externally; it **does** run on an interrupt cause flowing through the chain (`Effect.interrupt`). The natural probe for "does `catchCause` swallow interrupts?" is the former and returns the wrong answer. Probe with `Effect.interrupt.pipe(Effect.catchCause(h))` — the handler runs, `Cause.hasInterrupts(c)` is `true`, and the effect **succeeds**. It swallows interruption. That is almost never what you want. ## `Effect.cached` memoizes the `Exit` — failures *and interrupts* `Effect.cached(self)` returns `Effect>` whose inner effect replays the **first `Exit`**, whatever it was. Not just the success. A failure is cached. **An interrupt is cached.** That last one is the trap, because interruption is not a property of the effect at all — it is a property of whichever fiber happens to touch it first. An `Effect.timeout`, an `Effect.race`, a cancelled request, or a sibling failing under `Effect.all` will permanently poison the memo for every later caller. The replayed cause is an *interrupt*, which sits outside the effect's declared `E` channel and is not recoverable with `Effect.catch` — so a memoized value's declared error type becomes **unsound**. Failure caching is its own footgun: a caller reaching for the natural spelling, `Effect.retry(useTheMemo(), policy)`, silently no-ops — each retry replays the cached `Exit` without re-running the underlying effect. Library-side failure caching *destroys* the caller's ability to own the retry policy. **For success-only memoization**, invalidate on any non-success exit: ```ts const [resolve, invalidate] = Effect.runSync( Effect.cachedInvalidateWithTTL(expensiveEffect, Duration.infinity), ) const memo = Effect.onExit(resolve, (exit) => Exit.isSuccess(exit) ? Effect.void : invalidate, ) ``` Success is computed once, across sequential and concurrent observers. A failure or interrupt is retried on the next call, and callers bound their own retries by wrapping the *inner* effect. Reach for bare `Effect.cached` only when you genuinely want a terminal failure — and say so in the TSDoc, including the interrupt behavior, because no consumer will guess it. ## The `Effect.timeout` family — three forms, and timing out interrupts Exactly three exist — `timeoutFail` and `timeoutTo` are both `undefined`. | Form | On timeout | Signature shape | | --- | --- | --- | | `Effect.timeout(duration)` | fails with **`Cause.TimeoutError`** (added to `E`) | `Effect` | | `Effect.timeoutOption(duration)` | succeeds with `Option.none()` — no added error | `Effect, E, R>` | | `Effect.timeoutOrElse({ duration, orElse })` | runs the fallback (the old `timeoutTo`/`timeoutFail` shape) | `Effect` | **When the timeout wins, the source effect is interrupted** — its finalizers run, so scoped resources clean up: a timed-out subprocess spawn closes its scope and kills the child, a timed-out acquire releases. A per-operation ceiling is therefore `op.pipe(Effect.timeout("30 seconds"))` composed by the *caller* — never a bespoke timeout parameter threaded through a service. The one sanctioned exception is a **package-owned ceiling that is part of the service's error contract**: `@effected/git`'s `runClassified` owns a fixed 30s ceiling internally and maps expiry to its own `GitCommandError`, so `Cause.TimeoutError` never escapes its methods — the ceiling is absorbed into the taxonomy, not exposed as a parameter. ## Polling and repetition — `Effect.repeat` with options, not a recursive `Effect.sleep` A poll loop written as `const go = Effect.gen(function*() { ...; yield* Effect.sleep(d); return yield* go })` re-derives a schedule by hand and hides the stop condition in control flow. `Effect.repeat` (`Effect.ts:7656`) takes either a `Schedule` or an **options object** — `{ schedule?, times?, while?, until? }` — that `internal/schedule.ts:223` (`buildFromOptions`) folds into one schedule; `while` and `until` may return a `boolean` or an `Effect`, and they see the effect's **result**. `Effect.retry` takes the same option shape keyed on the failure instead. ```ts const status = yield* Effect.repeat(pollOnce, { schedule: Schedule.spaced("2 seconds"), until: (s): s is "done" => s === "done", // a refinement NARROWS the result type times: 30, }) ``` Three facts, each a trap for a hand-rolled loop: - **The effect runs once before the schedule is consulted**, so `times: 2` produces **three** runs (the doc's own gotcha, confirmed: a counter read 3); `times` counts repetitions, not executions. - **The value is the last result**, and an `until` written as a type guard narrows it — `Repeat.Return` (`Effect.ts:7508`) picks the refined type, so the example above types as `"done"`, not `"pending" | "done"`. `while` with a refinement narrows to the *excluded* branch. - **`until` alone with no `schedule` spins with no delay** (`passthroughForever` is the default schedule) — a poll against a remote must always pass `schedule: Schedule.spaced(...)` or it is a busy loop. ## `Predicate` helpers — never hand-write `isString` / record guards The official LLMS guidance, adopted as a house rule: **never** write your own type-guard helpers — the `Predicate` module ships them. A hand-rolled guard is both a duplication and a subtle-drift risk; retire any you find on contact. Two names that read as real but are not: **`Predicate.isRecord` and `Predicate.isPlainObject` do NOT exist** (probed; the vendored `Predicate.ts` has neither). The guards that DO ship: `isString`, `isNumber`, `isBoolean`, `isObject`, `isReadonlyObject`, `isObjectOrArray`, `isObjectKeyword`, `hasProperty`, `isTagged`, `isIterable`, `isNullish`/`isNotNullish`, `isTupleOf`, and friends — for a record check, pick the object refinement whose semantics you verified in the module, not a remembered name. ## Scope and resource management Resource idioms are unchanged — tie cleanup to a scope: ```ts const resource = Effect.acquireRelease( acquire, // Effect (a) => release(a) // runs when the scope closes ) Effect.scoped( Effect.gen(function* () { const a = yield* resource yield* Effect.addFinalizer(() => Effect.log("cleanup")) return yield* use(a) }) ) ``` `Effect.addFinalizer` registers ad-hoc cleanup on the current scope. To satisfy an effect's `Scope` requirement without closing the scope, use **`Scope.provide`** — both `Scope.provide(effect, scope)` and `effect.pipe(Scope.provide(scope))` work. **Clearing a reference and closing its scope is two steps, not one.** An interrupt that lands between `Ref.set(current, Option.none())` and `Scope.close(scope, Exit.void)` leaves the scope open and unreachable: its finalizers never run, and whatever it held (a permit, a mounted view, a forked tick) leaks. Make the pair one uninterruptible step: ```ts Effect.uninterruptible(Effect.andThen(Ref.set(current, Option.none()), Scope.close(scope, Exit.void))) ``` A low `Scheduler.MaxOpsBeforeYield` in a test is how to prove the window exists — see `effect-v4-testing`. ## Forking and fibers The fork verbs take an options object: - `Effect.forkChild` — child of the current fiber; there is no bare `Effect.fork`. - `Effect.forkDetach` — detached from the parent lifecycle; there is no `forkDaemon`. - `Effect.forkScoped` — tied to the current `Scope`. - `Effect.forkIn` — forked into a specific `Scope`. All four accept `{ startImmediately?: boolean; uninterruptible?: boolean | "inherit" }` (data-first and data-last). A `Fiber` is no longer an Effect, so await it explicitly: ```ts const fiber = yield* Effect.forkChild(work) const result = yield* Fiber.join(fiber) // or Fiber.await for the Exit ``` **Keep-alive is built in.** A fiber suspended on `Deferred.await` keeps the process alive without `runMain` — the core runtime has a reference-counted keep-alive timer. `runMain` (from the platform packages) is still the recommendation for SIGINT/SIGTERM handling, exit codes, and unhandled-error reporting, but it is no longer what keeps the event loop from draining. ## Fiber-local state — `Context.Reference` `FiberRef` and `FiberRefs` are removed (zero occurrences in core, and `effect/FiberRef` does not resolve as a module). **`Differ` is not** — it survives as a top-level module (`Differ.ts:27`, `interface Differ`) for patch-based value updates; it simply no longer has a `FiberRef` to serve. Fiber-local state is now a `Context.Reference` — a service with a default value. Read it by yielding it, and scope a new value with `Effect.provideService` (there is no free-floating `FiberRef.set` mutation and no `Effect.locally`): ```ts const Verbose = Context.Reference("Verbose", { defaultValue: () => false }) const program = Effect.gen(function* () { const verbose = yield* Verbose // reads the current value if (verbose) yield* Effect.log("noisy") }) // scope a value to a sub-effect (replaces Effect.locally / FiberRef.set): program.pipe(Effect.provideService(Verbose, true)) ``` Built-in fiber refs moved to the `References` module — read them the same way: `yield* References.CurrentLogLevel`, `References.MinimumLogLevel`, `References.TracerEnabled`, `References.CurrentLogAnnotations`, etc. **There is no `References.CurrentConcurrency`** — the module's own header prose says the references "cover concurrency, scheduling, logging, tracing" (`References.ts:4`), but no concurrency reference is exported, and a `yield*` against the remembered name gets `undefined`. Concurrency is an *option* in v4 (`{ concurrency }` on `Effect.all` and friends), not an ambient reference. The twelve that do exist: `CurrentLogAnnotations`, `CurrentLogLevel`, `CurrentLogSpans`, `CurrentStackFrame`, `MinimumLogLevel`, `TracerEnabled`, `TracerSpanAnnotations`, `TracerSpanLinks`, `TracerTimingEnabled`, `UnhandledLogLevel`, `CurrentLoggers`, `LogToStderr`. **Overriding the config provider is ordinary service provision.** There is no `Effect.withConfigProvider`; `ConfigProvider.ConfigProvider` is itself a `Context.Reference` (`ConfigProvider.ts:342`), so swap it with `Effect.provideService(effect, ConfigProvider.ConfigProvider, provider)`. Reach for a combinator name instead and you get `undefined is not a function` at the call site — which reads like a bad import, not a missing API. ## Runtime and entrypoints There is no `Runtime` type carrying services and flags — the ambient service set is just `Context`. The run functions live on `Effect`: - Capture the ambient services with `Effect.context()`, then run a program against them with `Effect.runForkWith(services)(program)`. - With no requirements: `Effect.runFork(effect)`. - Boundary choices: `Effect.runPromise` (hand off to a Promise host), `Effect.runFork` (background/long-running), `Effect.runSync` (sparingly). - The `Runtime` module now holds only process-lifecycle utils (`Runtime.makeRunMain`, `Runtime.defaultTeardown`). For an application with multiple entrypoints sharing one layer graph, build a `ManagedRuntime` once and reuse it: ```ts const runtime = ManagedRuntime.make(AppLayer) await runtime.runPromise(program) // runtime.runFork(...) / await runtime.dispose() on shutdown ``` ## Structural equality — deep by default `Equal.equals` is **structural by default** in v4 — no `structuralRegion` opt-in. Plain objects, arrays, `Map`, `Set`, `Date`, and `RegExp` compare by value: ```ts Equal.equals({ a: 1 }, { a: 1 }) // true Equal.equals(NaN, NaN) // true (NaN equals itself) ``` To force identity comparison, opt out per object: `Equal.byReference(obj)` (non-mutating Proxy) or `Equal.byReferenceUnsafe(obj)` (marks the object itself, faster, permanent). Derive an `Equivalence` with `Equal.asEquivalence()`. ## Some names that look like values are calls A nasty family: the name **does** exist and is spelled exactly like the value you want, but it is a **factory with an optional (or no) argument** — so the uncalled reference is a perfectly good expression and the mistake surfaces far away, as a construction throw or a service that was never provided: | Write | Not | Source | | --- | --- | --- | | `Schema.Defect()` | `Schema.Defect` | `Schema.ts:8732` — `function Defect(options?: ErrorOptions)`. The canonical case: `cause: Schema.Defect` on an error class throws at construction. | | `Schema.ErrorInstance()` | `Schema.ErrorInstance` | `Schema.ts:8656` — same shape, same optional-`options` trap, one page away in the same module. | | `TestClock.layer()` | `TestClock.layer` | `testing/TestClock.ts:436` — a *function* returning a Layer, unlike almost every other `layer` in core. | | `Schema.Literals(["a","b"])` | `Schema.Literals` | `Schema.ts:4800` — takes ONE array argument. | **The discriminator is the optional argument.** `Schema.Cause(e, d)` (`Schema.ts:10575`) and `Schema.Exit(...)` (`:12916`) are factories too, but their arguments are required, so forgetting to call them is an immediate type error. Only the zero-or-optional-arg factories type-check uncalled. **And do not over-correct: `layer` is usually a value.** `TestConsole.layer` is a plain `Layer.Layer` (`testing/TestConsole.ts:294`), so `TestConsole.layer()` is an error. The pair sits in the same directory and reads identically. Check the declaration; do not pattern-match on the name. ## Verify, don't remember One runtime probe beats an hour of type-error archaeology. From any package on the v4 catalog: ```bash node --input-type=module -e " import * as Effect from 'effect/Effect' console.log(typeof Effect.TheApiYouWant) " ``` If it prints `undefined`, the name moved — check `node_modules/effect/dist/` for the `.d.ts`, or climb the `effect-v4-source-lookup` ladder, before writing.