v4 Changes
Effect v4 introduces breaking changes across the core runtime, service definitions, error handling, module structure, and naming conventions. This page covers the major changes and how to migrate.
Comparison Table
| Area | v3 | v4 |
|---|---|---|
| Effect subtyping | Ref, Deferred, Fiber extend Effect | Yieldable trait; use explicit methods |
| Services | Context.Tag, Effect.Tag, Effect.Service | Context.Service |
| Catch all | Effect.catchAll | Effect.catch |
| Catch all cause | Effect.catchAllCause | Effect.catchCause |
| Catch some | Effect.catchSome | Effect.catchFilter |
| Either | effect/Either | effect/Result |
| STM refs | TRef, TMap, TSet | TxRef, TxHashMap, TxHashSet |
| Fiber keep-alive | Requires runMain timer hack | Built into core runtime |
| Layer memoization | Per Effect.provide call | Shared across Effect.provide calls |
| Platform packages | @effect/platform, @effect/cli, etc. | effect/unstable/* |
| Schema filter | Schema.filter(predicate) | Schema.check(Schema.makeFilter(...)) |
| Schema transform | Schema.transform(from, to, opts) | from.pipe(Schema.decodeTo(to, ...)) |
| ParseResult | effect/ParseResult | effect/SchemaIssue, effect/SchemaParser |
| TestClock | effect/TestClock | effect/testing/TestClock |
| FiberRef | effect/FiberRef | effect/References |
Yieldable Trait
v3 made many types structural subtypes of Effect. You could pass a Ref to Effect.map and it would silently read the ref’s value. This caused subtle bugs.
v4 introduces the Yieldable trait: a narrower contract that allows yield* in generators but does not make the type assignable to Effect.
// yield* still works with Yieldable types
const program = Effect.gen(function*() {
const value = yield* Option.some(42) // Option is Yieldable
return value
})
But combinators require explicit conversion:
// v3: Option<number> is assignable to Effect<number, NoSuchElementError>
const program = Effect.map(Option.some(42), (n) => n + 1)
// v4: use .asEffect() or a generator
const program = Effect.map(Option.some(42).asEffect(), (n) => n + 1)
const program2 = Effect.gen(function*() {
const n = yield* Option.some(42)
return n + 1
})
Types No Longer Subtypes of Effect
| Type | v3 pattern | v4 replacement |
|---|---|---|
Ref | yield* ref | yield* Ref.get(ref) |
Deferred | yield* deferred | yield* Deferred.await(deferred) |
Fiber | yield* fiber | yield* Fiber.join(fiber) |
Gotcha: If you have
yield* someRefin v3 code, this will fail to compile in v4. Replace withyield* Ref.get(someRef). The same applies toDeferredandFiber.
Context.Service Pattern
v4 replaces all service definition APIs with Context.Service.
Class-Based Services
// v3
class Database extends Context.Tag("Database")<Database, {
query(sql: string): Effect.Effect<Array<unknown>>
}>() {}
// v4
class Database extends Context.Service<Database, {
query(sql: string): Effect.Effect<Array<unknown>>
}>()("myapp/Database") {}
Note the argument order: in v3, the identifier string comes first. In v4, the type parameters come first, then the identifier string.
Effectful Constructors with make
v3’s Effect.Service auto-generated a .Default layer. v4’s Context.Service with make stores the constructor but does not auto-generate a layer. You define layers explicitly.
// v4
class Logger extends Context.Service<Logger>()("myapp/Logger", {
make: Effect.gen(function*() {
const config = yield* Config
return { log: (msg: string) => Effect.log(`[${config.prefix}] ${msg}`) }
})
}) {
static readonly layer = Layer.effect(this, this.make).pipe(
Layer.provide(Config.layer)
)
}
Accessors Removed
v3’s Effect.Tag provided static accessor proxies. v4 removes them. Use yield* or Service.use:
// v3: static proxy access
const program = Notifications.notify("hello")
// v4: yield the service
const program = Effect.gen(function*() {
const notifications = yield* Notifications
yield* notifications.notify("hello")
})
// or use:
const program = Notifications.use((n) => n.notify("hello"))
Tip: Prefer
yield*overuse. It makes dependencies explicit at the call site and keeps service access co-located with your effect logic.usecan accidentally hide service dependencies in return values.
Quick Reference
| 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, opts) | Context.Service<Self>()(id, { make }) |
catch* Renamings
| v3 | v4 |
|---|---|
Effect.catchAll | Effect.catch |
Effect.catchAllCause | Effect.catchCause |
Effect.catchAllDefect | Effect.catchDefect |
Effect.catchSome | Effect.catchFilter |
Effect.catchSomeCause | Effect.catchCauseFilter |
Effect.catchSomeDefect | Removed |
Effect.catchTag | Effect.catchTag (unchanged) |
Effect.catchTags | Effect.catchTags (unchanged) |
Effect.catchIf | Effect.catchIf (unchanged) |
catchSome to catchFilter
v3’s catchSome took a function returning Option<Effect>. v4’s catchFilter uses the Filter module.
// v3
Effect.fail(42).pipe(
Effect.catchSome((error) =>
error === 42 ? Option.some(Effect.succeed("caught")) : Option.none()
)
)
// v4
import { Effect, Filter } from "effect"
Effect.fail(42).pipe(
Effect.catchFilter(
Filter.fromPredicate((error: number) => error === 42),
(error) => Effect.succeed("caught")
)
)
New in v4
Effect.catchReason(errorTag, reasonTag, handler)- catches a specific reason within a tagged errorEffect.catchReasons(errorTag, cases)- handles multiple reason tagsEffect.catchEager(handler)- optimization variant for synchronous recovery
Automatic Fiber Keep-Alive
v3’s core runtime did not keep the process alive while fibers were suspended on async operations. You needed runMain from @effect/platform-node to install a timer.
v4 builds keep-alive into the core runtime. The fiber runtime automatically manages a reference-counted keep-alive timer.
// v4: works without runMain
import { Deferred, Effect } from "effect"
const program = Effect.gen(function*() {
const deferred = yield* Deferred.make<string>()
yield* Deferred.await(deferred)
})
Effect.runPromise(program) // process stays alive
Tip:
runMainis still recommended for production. It provides signal handling (SIGINT/SIGTERM), exit code management, and error reporting. The keep-alive change just means the core runtime no longer exits prematurely.
Layer Auto-Memoization
v3 memoized layers within a single Effect.provide call, but not across separate calls. Two Effect.provide calls with overlapping layers would build them twice.
v4 shares the MemoMap between Effect.provide calls by default:
const main = program.pipe(
Effect.provide(MyServiceLayer),
Effect.provide(MyServiceLayer) // v3: builds twice. v4: builds once
)
Opting Out
Use Layer.fresh to bypass the shared cache, or Effect.provide(layer, { local: true }) for an entire isolated layer subtree:
const main = program.pipe(
Effect.provide(MyServiceLayer),
Effect.provide(Layer.fresh(MyServiceLayer)) // always builds fresh
)
Gotcha: Even though v4 auto-memoizes across
providecalls, composing layers before providing is still the recommended pattern. Auto-memoization is a safety net, not a substitute for explicit dependency wiring.
Consolidated Packages
v4 merges previously separate packages into effect/unstable/*:
| v3 package | v4 location |
|---|---|
@effect/platform (HTTP, FileSystem, etc.) | effect/unstable/http, effect/FileSystem, effect/Path |
@effect/platform (HttpApi) | effect/unstable/httpapi |
@effect/cli | effect/unstable/cli |
@effect/cluster | effect/unstable/cluster |
@effect/ai | effect/unstable/ai |
@effect/sql | effect/unstable/sql |
@effect/rpc | effect/unstable/rpc |
@effect/workflow | effect/unstable/workflow |
@effect/opentelemetry/Otlp* | effect/unstable/observability/Otlp* |
Warning:
unstable/modules may break in minor releases. Stabilized modules move toeffect/*top-level. Import specific modules to keep bundles small:import { HttpApi } from "effect/unstable/httpapi".
Either to Result
v4 renames Either to Result across the board:
// v3
import { Either } from "effect"
const right = Either.right(42)
const isRight = Either.isRight(value)
// v4
import { Result } from "effect"
const right = Result.right(42)
const isRight = Result.isSuccess(value)
The Either import path effect/Either is now effect/Result.
Tx* Prefix for STM Types
Software transactional memory types are renamed with a Tx prefix:
| v3 | v4 |
|---|---|
TRef | TxRef |
TMap | TxHashMap |
TSet | TxHashSet |
TQueue | TxQueue |
TPubSub | TxPubSub |
TSemaphore | TxSemaphore |
TDeferred | TxDeferred |
TSubscriptionRef | TxSubscriptionRef |