| name | effect-ts |
| description | Write TypeScript with the Effect V4 library. Covers the core Effect type, generator syntax (Effect.gen/Effect.fn), typed errors, services and dependency injection via Context/Layer, resource management, streams, HTTP client/server, CLI, child processes, testing, observability, AI modules, and cluster. Use when writing or reviewing Effect code, migrating vanilla TypeScript to Effect, or when you need to understand Effect's service/layer/error model. |
Effect V4 for TypeScript
Effect is a TypeScript library that replaces ad-hoc patterns for error handling,
dependency injection, concurrency, resource management, and observability with a
single composable system. The core type Effect<A, E, R> tracks success (A),
typed errors (E), and required dependencies (R) at the type level.
This skill covers Effect V4, which introduces Context.Service (replacing the
older Context.Tag, Effect.Tag, and Effect.Service patterns), Effect.fn
(replacing functions that return Effect.gen), and several new modules under
effect/unstable/*.
Detailed references
For HTTP, streams, testing, and auxiliary modules:
- HTTP: See references/http.md — HttpClient, HttpApi server, generated typed client, middleware
- Streams: See references/streams.md — creating, transforming, consuming, Ndjson/Msgpack encoding
- Testing: See references/testing.md — @effect/vitest, shared layer tests, test clock, property tests
- Auxiliary modules: See references/auxiliary-modules.md — CLI, child processes, schedules, PubSub, AI modules, cluster
Why Effect over vanilla TypeScript
Errors are untyped. throw puts values into a black hole that
catch(e: unknown) can't inspect without runtime checks. Effect's E type
parameter makes the error channel visible to the compiler — errors compose
through generators automatically, and catchTag/catchTags give you
exhaustive pattern matching.
Dependency injection is either heavy or manual. Decorator-based DI
(inversify, tsyringe) requires runtime reflection. Manual constructor injection
gets tedious. Effect's R type parameter tracks dependencies at compile time —
yield* a service and the compiler adds it to R; Layer.provide removes it.
Resource cleanup is fragile. try/finally doesn't compose across async
boundaries or service boundaries. Effect's Scope system ties resource
lifetimes to layers — acquireRelease guarantees cleanup even under
interruption.
Retry, timeout, concurrency are reimplemented everywhere. Effect provides
composable Schedule primitives, Effect.timeout, and concurrency control on
every operator that needs it.
Effect.gen — imperative-style Effect code
Effect.gen uses generator functions with yield* to unwrap effects, similar
to async/await for Promises but with typed errors and dependencies.
import { Effect, Schema } from "effect"
Effect.gen(function*() {
yield* Effect.log("Starting...")
const data = yield* loadData()
if (!data.valid) {
return yield* new ValidationError({ message: "bad data" })
}
return data.value
}).pipe(
Effect.catch((error) => Effect.succeed("fallback")),
Effect.withSpan("processData")
)
Effect.fn — named functions that return Effects
When writing a function that returns an Effect, use Effect.fn instead of a
function that returns Effect.gen. Effect.fn attaches a tracing span
automatically and improves stack traces.
export const processOrder = Effect.fn("processOrder")(
function*(orderId: string): Effect.fn.Return<Receipt, OrderError> {
yield* Effect.logInfo("Processing order", orderId)
const order = yield* fetchOrder(orderId)
return yield* chargeCard(order)
},
Effect.retry(Schedule.exponential("200 millis")),
Effect.annotateLogs({ module: "orders" })
)
Creating effects from common sources
Effect.succeed({ env: "prod" })
Effect.sync(() => Date.now())
Effect.try({
try: () => JSON.parse(input),
catch: (cause) => new ParseError({ input, cause })
})
Effect.tryPromise({
try: () => fetch(url).then((r) => r.json()),
catch: (cause) => new FetchError({ url, cause })
})
Effect.fromNullishOr(map.get("key")).pipe(
Effect.mapError(() => new MissingKeyError())
)
Effect.callback<number>((resume) => {
const id = setTimeout(() => resume(Effect.succeed(42)), 100)
return Effect.sync(() => clearTimeout(id))
})
Errors
Defining errors
All custom errors should use Schema.TaggedErrorClass. This gives you a
discriminated union with a _tag field and integration with catchTag.
export class ParseError extends Schema.TaggedErrorClass<ParseError>()(
"ParseError",
{ input: Schema.String, message: Schema.String }
) {}
export class NetworkError extends Schema.TaggedErrorClass<NetworkError>()(
"NetworkError",
{ statusCode: Schema.Number, cause: Schema.Defect }
) {}
export class SmtpError extends Schema.ErrorClass<SmtpError>("SmtpError")({
cause: Schema.Defect
}) {}
export class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()(
"UserNotFound", {}, { httpApiStatus: 404 }
) {}
Catching errors
effect.pipe(Effect.catch((_) => Effect.succeed("fallback")))
effect.pipe(Effect.catchTag("ParseError", (e) => ...))
effect.pipe(Effect.catchTag(["ParseError", "NetworkError"], (e) => ...))
effect.pipe(Effect.catchTags({
ParseError: (e) => Effect.succeed(`parse: ${e.message}`),
NetworkError: (e) => Effect.succeed(`network: ${e.statusCode}`)
}))
Reason errors — nested error hierarchies
When a service wraps another service's errors, use a reason field:
export class AiError extends Schema.TaggedErrorClass<AiError>()("AiError", {
reason: Schema.Union([RateLimitError, QuotaExceededError, SafetyBlockedError])
}) {}
effect.pipe(Effect.catchReason("AiError", "RateLimitError",
(reason) => Effect.succeed(`retry after ${reason.retryAfter}s`),
(reason) => Effect.succeed(`unhandled: ${reason._tag}`)
))
effect.pipe(Effect.catchReasons("AiError", {
RateLimitError: (r) => Effect.succeed(`retry after ${r.retryAfter}s`),
QuotaExceededError: (r) => Effect.succeed(`quota: ${r.limit}`)
}))
effect.pipe(
Effect.unwrapReason("AiError"),
Effect.catchTags({
RateLimitError: ...,
QuotaExceededError: ...,
SafetyBlockedError: ...
})
)
Services and dependency injection
Context.Service
The V4 way to define a service. Replaces the older Context.Tag,
Effect.Tag, and Effect.Service patterns.
import { Context, Effect, Layer, Schema } from "effect"
export class Database extends Context.Service<Database, {
query(sql: string): Effect.Effect<Array<unknown>, DatabaseError>
}>()(
"myapp/db/Database"
) {
static readonly layer = Layer.effect(
Database,
Effect.gen(function*() {
const query = Effect.fn("Database.query")(function*(sql: string) {
yield* Effect.log("Executing:", sql)
return [{ id: 1, name: "Alice" }]
})
return Database.of({ query })
})
)
}
The class is never instantiated with new. It serves as a type, a tag, a
namespace for layers, and a factory via .of(). Consume it by yielding:
Effect.gen(function*() {
const db = yield* Database
const rows = yield* db.query("SELECT * FROM users")
})
Context.Reference — config and feature flags
export const FeatureFlag = Context.Reference<boolean>("myapp/FeatureFlag", {
defaultValue: () => false
})
Layer composition
static readonly layer = this.layerNoDeps.pipe(Layer.provide(Database.layer))
static readonly layerWithDb = this.layerNoDeps.pipe(
Layer.provideMerge(Database.layer)
)
const AppLayer = Layer.mergeAll(HttpServerLayer, WorkerLayer, MetricsLayer)
The standard pattern for services with dependencies:
export class UserRepository extends Context.Service<UserRepository, {
findById(id: string): Effect.Effect<Option.Option<User>, UserRepoError>
}>()(
"myapp/UserRepository"
) {
static readonly layerNoDeps: Layer.Layer<
UserRepository, never, SqlClient.SqlClient
> = Layer.effect(UserRepository, Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
const findById = Effect.fn("UserRepository.findById")(function*(id) {
const results = yield* sql`SELECT * FROM users WHERE id = '${id}'`
return Array.head(results)
})
return UserRepository.of({ findById })
}))
static readonly layer = this.layerNoDeps.pipe(Layer.provide(SqlClientLayer))
}
Layer.unwrap — dynamic layer selection
static readonly layer = Layer.unwrap(Effect.gen(function*() {
const useInMemory = yield* Config.boolean("IN_MEMORY").pipe(
Config.withDefault(false)
)
return useInMemory
? MessageStore.layerInMemory
: MessageStore.layerRemote(yield* Config.url("STORE_URL"))
}))
Resource management
Effect.acquireRelease
const transporter = yield* Effect.acquireRelease(
Effect.sync(() => createTransport(config)),
(t) => Effect.sync(() => t.close())
)
When used inside Layer.effect, the resource lives as long as the layer.
Effect.addFinalizer
const pubsub = yield* PubSub.bounded<Event>({ capacity: 256 })
yield* Effect.addFinalizer(() => PubSub.shutdown(pubsub))
Background tasks with forkScoped
const BackgroundWorker = Layer.effectDiscard(Effect.gen(function*() {
yield* Effect.gen(function*() {
while (true) {
yield* Effect.sleep("5 seconds")
yield* Effect.logInfo("tick")
}
}).pipe(
Effect.onInterrupt(() => Effect.logInfo("worker stopped")),
Effect.forkScoped
)
}))
LayerMap.Service — dynamic keyed resource pools
export class PoolMap extends LayerMap.Service<PoolMap>()("app/PoolMap", {
lookup: (tenantId: string) => DatabasePool.layer(tenantId),
idleTimeToLive: "1 minute"
}) {}
const pool = yield* DatabasePool
yield* pool.query("SELECT * FROM users")
yield* myEffect.pipe(Effect.provide(PoolMap.get("acme")))
yield* PoolMap.invalidate("acme")
Running programs
import { NodeRuntime } from "@effect/platform-node"
NodeRuntime.runMain(myEffect)
Layer.launch(AppLayer).pipe(NodeRuntime.runMain)
runMain installs SIGINT/SIGTERM handlers and interrupts fibers for graceful
shutdown. Every acquireRelease, forkScoped, and addFinalizer runs.
ManagedRuntime — bridging into existing frameworks
const runtime = ManagedRuntime.make(TodoRepo.layer, {
memoMap: Layer.makeMemoMapUnsafe()
})
app.get("/todos", async (c) => {
const todos = await runtime.runPromise(
TodoRepo.use((repo) => repo.getAll)
)
return c.json(todos)
})
process.once("SIGINT", () => void runtime.dispose())
Observability
Logging
import { Config, Effect, Layer, Logger, References } from "effect"
Logger.layer([Logger.consoleJson])
Layer.succeed(References.MinimumLogLevel, "Warn")
Logger.layer([Logger.toFile(Logger.formatSimple, "app.log")])
Layer.unwrap(Effect.gen(function*() {
const env = yield* Config.string("NODE_ENV").pipe(Config.withDefault("dev"))
return env === "production"
? Logger.layer([appLogger])
: Logger.layer([Logger.defaultLogger])
}))
myEffect.pipe(
Effect.annotateLogs({ service: "checkout" }),
Effect.withLogSpan("checkout")
)
Tracing
import { OtlpTracer, OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
const ObservabilityLayer = Layer.merge(
OtlpTracer.layer({ url: "http://localhost:4318/v1/traces", resource: { ... } }),
OtlpLogger.layer({ url: "http://localhost:4318/v1/logs", resource: { ... } })
).pipe(
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer)
)
const Main = AppLayer.pipe(Layer.provide(ObservabilityLayer))
Spans are added automatically by Effect.fn("name"). Add more with
Effect.withSpan, Effect.annotateSpans, and Layer.withSpan.
Schema quick reference
import { Schema } from "effect"
class User extends Schema.Class<User>("User")({
id: Schema.Number, name: Schema.String
}) {}
const UserId = Schema.Int.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
Schema.Literals(["info", "warn", "error"])
Schema.FiniteFromString.pipe(Schema.decodeTo(UserId))
Schema.DateTimeUtcFromString
Schema.decodeUnknownSync(User)({ id: 1, name: "Alice" })
Schema.String.annotate({ description: "The search query" })
Import paths
import { Context, Effect, Layer, Schema, Stream, PubSub, ... } from "effect"
import { FetchHttpClient, HttpClient, HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi"
import { Ndjson, Msgpack } from "effect/unstable/encoding"
import { OtlpTracer, OtlpLogger } from "effect/unstable/observability"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { Argument, Command, Flag } from "effect/unstable/cli"
import { LanguageModel, Tool, Toolkit, Chat } from "effect/unstable/ai"
import { SqlClient } from "effect/unstable/sql"
import { ClusterSchema, Entity } from "effect/unstable/cluster"
import { Rpc } from "effect/unstable/rpc"
import { NodeRuntime, NodeHttpServer, NodeServices } from "@effect/platform-node"
import { BunRuntime } from "@effect/platform-bun"
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai"