Migrating from v3
Effect v4 keeps the programming model you already know — Effect, Layer,
Schema, Stream are conceptually unchanged — but it reorganises packages,
renames a family of combinators, and tightens a few type-level contracts. This
page summarises the changes you are most likely to hit and shows the v4 form
side by side with the v3 form.
A service, end to end
Section titled “A service, end to end”Most v3 code that defines a service, builds a layer, and runs a program needs only mechanical changes. Here is the same program written in both versions, so you can see the shape of the migration at a glance.
import { Context, Effect, Layer } from "effect"
// Services are classes extending Context.Service<Self, Shape>()(id).// The type parameters come first, the identifier is passed afterwards.class Database extends Context.Service<Database, { readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>>}>()("app/Database") {}
// A layer is built explicitly with Layer.effect — there is no auto-generated// `.Default`. The convention is to name the primary layer `layer`.const DatabaseLayer = Layer.effect( Database, Effect.succeed({ query: (sql) => Effect.succeed([{ sql }]) }))
// Access the service with yield* in a generator — dependencies stay explicit.const program = Effect.gen(function*() { const db = yield* Database return yield* db.query("SELECT 1")})
program.pipe(Effect.provide(DatabaseLayer), Effect.runPromise)import { Effect } from "effect"
// Services were defined with Effect.Service and auto-generated a `.Default`// layer from the `effect` constructor.class Database extends Effect.Service<Database>()("app/Database", { effect: Effect.succeed({ query: (sql: string) => Effect.succeed([{ sql }]) })}) {}
const program = Effect.gen(function*() { const db = yield* Database return yield* db.query("SELECT 1")})
program.pipe(Effect.provide(Database.Default), Effect.runPromise)The accessing code (yield* Database) is identical. What changed is how the
service is defined and how its layer is built — covered next.
Packaging and imports
Section titled “Packaging and imports”Two structural changes affect every project, regardless of which APIs you use.
Single version number. All Effect ecosystem packages now share one version
and are released together. If you depend on effect@4.0.0-beta.0, the matching
SQL driver is @effect/sql-pg@4.0.0-beta.0. In v3 these were versioned
independently, which made compatibility hard to track.
Package consolidation. Functionality from @effect/platform,
@effect/rpc, @effect/cluster, and others now lives inside the core effect
package. Stable modules are imported from effect; the ported-but-evolving ones
live under effect/unstable/<area>:
// Stable core — top-level barrelimport { Effect, Layer, Schema, Stream } from "effect"
// Unstable modules — versioned more loosely, may change in minor releasesimport { HttpClient } from "effect/unstable/http"import { Command } from "effect/unstable/cli"import { LanguageModel } from "effect/unstable/ai"Packages that stay separate are platform-, driver-, or provider-specific:
@effect/platform-node, @effect/sql-pg, @effect/ai-openai,
@effect/opentelemetry, @effect/vitest. Bump them to matching v4 versions.
Defining services: Context.Service
Section titled “Defining services: Context.Service”In v3, services could be defined with Context.Tag, Context.GenericTag,
Effect.Tag, or Effect.Service. In v4, all of these collapse into a single
constructor: Context.Service.
import { Context, Effect, Layer } from "effect"
class Logger extends Context.Service<Logger>()("app/Logger", { // `make` stores the constructor effect on the class but does NOT // auto-generate a layer (unlike v3's Effect.Service). make: Effect.succeed({ log: (msg: string) => Effect.log(msg) })}) { // Build the layer yourself. v4 names it `layer`, not `Default`/`Live`. static readonly layer = Layer.effect(this, this.make)}The most important behavioural change: the dependencies option is gone. Wire
a service’s dependencies with Layer.provide when you build its layer:
static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(Config.layer))References (services with defaults) are still Context.Reference, but the
function form is used in v4:
import { Context } from "effect"
const LogLevel = Context.Reference<"info" | "warn" | "error">("app/LogLevel", { defaultValue: () => "info" as const})| v3 | v4 |
| --- | --- |
| Context.GenericTag<T>(id) | Context.Service<T>(id) |
| Context.Tag(id)<Self, Shape>() | Context.Service<Self, Shape>()(id) |
| Effect.Tag(id)<Self, Shape>() | Context.Service<Self, Shape>()(id) |
| Effect.Service<Self>()(id, { effect, dependencies }) | Context.Service<Self>()(id, { make }) + explicit Layer |
| tag.Default / tag.Live | tag.layer (named by convention) |
FiberRef is gone — use Context.Reference
Section titled “FiberRef is gone — use Context.Reference”FiberRef, FiberRefs, FiberRefsPatch, and Differ were removed. Fiber-local
state is now a Context.Reference — the same mechanism as services with
defaults. The built-in refs moved onto the References module:
import { Effect, References } from "effect"
// Read a built-in reference by yielding it directly.const program = Effect.gen(function*() { const level = yield* References.CurrentLogLevel return level})
// Scope a value for the duration of an effect with provideService —// this replaces v3's Effect.locally / FiberRef.set.const debugged = program.pipe( Effect.provideService(References.CurrentLogLevel, "Debug"))| v3 | v4 |
| --- | --- |
| FiberRef.get(FiberRef.currentLogLevel) | yield* References.CurrentLogLevel |
| Effect.locally(eff, ref, value) | Effect.provideService(eff, Reference, value) |
| FiberRef.currentConcurrency | References.CurrentConcurrency |
| FiberRef.currentScheduler | References.Scheduler |
Error handling: catchAll* → catch*
Section titled “Error handling: catchAll* → catch*”The catch family was shortened: catchAll* drops the All, and the
catchSome* family is replaced by filter-based combinators.
import { Effect } from "effect"
const recovered = Effect.fail("boom").pipe( // v3: Effect.catchAll Effect.catch((error) => Effect.succeed(`recovered: ${error}`)))| v3 | v4 |
| --- | --- |
| Effect.catchAll | Effect.catch |
| Effect.catchAllCause | Effect.catchCause |
| Effect.catchAllDefect | Effect.catchDefect |
| Effect.catchSome | Effect.catchFilter |
| Effect.catchSomeCause | Effect.catchCauseFilter |
| Effect.catchTag / catchTags / catchIf | unchanged |
v4 also adds Effect.catchReason / catchReasons for handling a nested
reason within a tagged error without removing the parent error from the error
channel — useful for structured errors such as an AiError carrying a
RateLimitError reason.
Either → Result
Section titled “Either → Result”Either has been renamed to Result. Same shape, new names for the
constructors:
import { Result } from "effect"
const ok = Result.succeed(42) // was Either.rightconst no = Result.fail("nope") // was Either.leftSeveral extractors now return a Result where v3 returned an Option. For
example, Cause.findError returns a Result; use Cause.findErrorOption for
the Option-based variant.
Forking: clearer names, options object
Section titled “Forking: clearer names, options object”The fork* family was renamed for clarity, and every variant now takes an
options object (startImmediately, uninterruptible).
import { Effect, Fiber } from "effect"
const program = Effect.gen(function*() { // v3: Effect.fork — a child fiber tied to the parent's lifetime. const fiber = yield* Effect.forkChild(longRunningTask) // Fiber is no longer an Effect — join it explicitly (see below). return yield* Fiber.join(fiber)})
declare const longRunningTask: Effect.Effect<number>| v3 | v4 |
| --- | --- |
| Effect.fork | Effect.forkChild |
| Effect.forkDaemon | Effect.forkDetach |
| Effect.forkScoped / forkIn | unchanged |
| Effect.forkAll / forkWithErrorHandler | removed (fork individually) |
Yieldable: explicit conversion to Effect
Section titled “Yieldable: explicit conversion to Effect”In v3 many types (Ref, Deferred, Fiber, Option, Either, …) were
structural subtypes of Effect and could be passed anywhere an Effect was
expected. This caused subtle bugs — accidentally mapping over a Ref instead of
its value, for instance.
v4 replaces subtyping with the narrower Yieldable trait: a type can be
yield*-ed in a generator but is not assignable to Effect. So yield*
still works as before, while passing the value to a combinator now requires an
explicit conversion.
import { Effect, Option, Ref } from "effect"
const program = Effect.gen(function*() { // yield* still works on Yieldable values, exactly like v3. const a = yield* Option.some(42)
// Ref/Deferred/Fiber are plain values now — read them through their module. const ref = yield* Ref.make(0) const b = yield* Ref.get(ref) // v3: `yield* ref` return a + b})
// Outside a generator, convert a Yieldable explicitly with Effect.fromOption// (or Effect.fromResult for a Result) before passing it to a combinator.const mapped = Effect.map(Effect.fromOption(Option.some(42)), (n) => n + 1)| v3 (was an Effect) | v4 (read via module) |
| --- | --- |
| yield* ref | yield* Ref.get(ref) |
| yield* deferred | yield* Deferred.await(deferred) |
| yield* fiber | yield* Fiber.join(fiber) |
Cause is flat
Section titled “Cause is flat”Cause is no longer a recursive tree of Sequential / Parallel nodes. It is
now a flat wrapper over an array of Reason values, where a reason is one of
Fail, Die, or Interrupt:
import { Cause } from "effect"
const summarise = (cause: Cause.Cause<string>) => { // Iterate the flat reasons array instead of recursing a tree. for (const reason of cause.reasons) { switch (reason._tag) { case "Fail": return reason.error case "Die": return reason.defect case "Interrupt": return reason.fiberId } }}The *Exception classes were renamed to *Error (e.g.
NoSuchElementException → Cause.NoSuchElementError,
TimeoutException → Cause.TimeoutError).
Other notable renames
Section titled “Other notable renames”A few changes you will run into while porting:
| v3 | v4 |
| --- | --- |
| Effect.async | Effect.callback |
| Effect.either | Effect.result |
| Effect.zipRight / zipLeft | Effect.andThen / Effect.tap |
| Layer.scoped | Layer.effect |
| Scope.extend | Scope.provide |
| Runtime<R> / Runtime.runFork(runtime) | removed — use Context<R> + Effect.runForkWith(services) |
| Stream.fromChunk / fromChunks | Stream.fromArray / fromArrays |
| Stream.async | Stream.callback |
| effect/JSONSchema | effect/JsonSchema |
Equal.equals also now uses structural equality by default for plain
objects, arrays, Maps, Sets, Dates, and RegExps — no structuralRegion
needed. Opt back into reference equality with Equal.byReference.
Verifying the change
Section titled “Verifying the change”The fiber runtime was rewritten for lower memory overhead and faster execution, and the core package tree-shakes aggressively — a minimal Effect program bundles to roughly 6.3 KB minified + gzipped, or ~15 KB with Schema. After porting, your existing test suite is the best signal that behaviour is preserved; see Testing for the v4 testing utilities.
For the complete machine-generated import and API rename maps, see the
migration/ directory in the effect-smol repository.