Skip to content

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.

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)

The accessing code (yield* Database) is identical. What changed is how the service is defined and how its layer is built — covered next.

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 barrel
import { Effect, Layer, Schema, Stream } from "effect"
// Unstable modules — versioned more loosely, may change in minor releases
import { 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.

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 |

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 has been renamed to Result. Same shape, new names for the constructors:

import { Result } from "effect"
const ok = Result.succeed(42) // was Either.right
const no = Result.fail("nope") // was Either.left

Several 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.

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

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 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. NoSuchElementExceptionCause.NoSuchElementError, TimeoutExceptionCause.TimeoutError).

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.

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.