Error handling in TypeScript has always felt incomplete. You throw exceptions that vanish from type signatures, write try-catch blocks that swallow failures silently, and hope that runtime errors surface before they reach production. The Effect library changes this equation entirely. By encoding errors, dependencies, and asynchronous operations directly into the type system, Effect gives you compile-time guarantees about program correctness that were previously impossible in TypeScript.
This guide walks through the core patterns that make Effect a compelling choice for building resilient TypeScript applications. We will cover typed error channels, composable pipelines, dependency injection through the Layer system, resource management, and concurrency control. Each section includes practical code that you can adapt for your own projects.
Understanding the Effect Type
At the heart of the library is the Effect<Success, Error, Requirements> type. Unlike a plain Promise<T>, this type carries three channels of information. The Success type describes what the effect produces when it succeeds. The Error type describes every possible failure mode. The Requirements type lists the services that must be provided before the effect can run.
import { Effect } from "effect"
// An effect that succeeds with a string, can fail with a
// NetworkError or ParseError, and requires a HttpClient service
type FetchUser = Effect.Effect<
User,
NetworkError | ParseError,
HttpClient
>
// A simple effect that always succeeds
const greeting = Effect.succeed("Hello, Effect!")
// An effect that always fails
const failure = Effect.fail(new NetworkError("Connection refused"))
// An effect created from a fallible operation
const parseJson = (raw: string) =>
Effect.try({
try: () => JSON.parse(raw) as User,
catch: (error) => new ParseError(String(error))
})
The critical insight is that Error is not unknown. Every function that can fail declares its failure type explicitly. When you compose effects together, the compiler accumulates error types automatically, creating a union of all possible failures. You cannot run the final program until every error has been handled or explicitly propagated.
This approach eliminates the guessing game that plagues traditional TypeScript error handling. When you see a function's return type, you know exactly what can go wrong. If you are building APIs with end-to-end type safety using tRPC, Effect's typed errors complement that stack by ensuring server-side logic is equally rigorous.
Composable Error Handling
Effect provides multiple strategies for handling errors, each appropriate for different situations. The simplest is Effect.catchAll, which handles every error in the channel. For more granular control, Effect.catchTag lets you handle specific error types by their discriminant.
class NetworkError {
readonly _tag = "NetworkError"
constructor(readonly message: string) {}
}
class ValidationError {
readonly _tag = "ValidationError"
constructor(readonly field: string, readonly reason: string) {}
}
class TimeoutError {
readonly _tag = "TimeoutError"
constructor(readonly duration: number) {}
}
const fetchUserProfile = (id: string): Effect.Effect<
UserProfile,
NetworkError | ValidationError | TimeoutError,
HttpClient
> =>
pipe(
httpClient.get(`/users/${id}`),
Effect.flatMap(parseResponse),
Effect.flatMap(validateProfile),
Effect.timeout("5 seconds"),
)
// Handle only network errors, letting others propagate
const withRetry = pipe(
fetchUserProfile("user-123"),
Effect.catchTag("NetworkError", (err) =>
Effect.retry(
fetchUserProfile("user-123"),
Schedule.exponential("100 millis").pipe(
Schedule.compose(Schedule.recurs(3))
)
)
)
)
// Type: Effect<UserProfile, ValidationError | TimeoutError, HttpClient>
Notice how catching NetworkError removes it from the error channel. The resulting type only contains ValidationError | TimeoutError. The compiler tracks this automatically, so you always know which errors remain unhandled. This is a fundamental difference from exception-based code where error tracking is purely a documentation exercise.
You can also transform errors using Effect.mapError to convert low-level failures into domain-specific types, or use Effect.catchAll to provide a fallback value when any error occurs.
// Convert all errors to a single AppError type for API responses
const apiHandler = pipe(
fetchUserProfile("user-123"),
Effect.mapError((error) => {
switch (error._tag) {
case "NetworkError":
return new AppError(503, "Service temporarily unavailable")
case "ValidationError":
return new AppError(400, `Invalid ${error.field}: ${error.reason}`)
case "TimeoutError":
return new AppError(504, "Request timed out")
}
})
)
Dependency Injection with Layers
Effect replaces traditional dependency injection containers with a compile-time verified system built on two concepts: Context.Tag for declaring service interfaces, and Layer for providing implementations. This is arguably the most powerful feature in the library because it makes your dependency graph a first-class citizen of the type system.
import { Context, Layer, Effect } from "effect"
// Declare a service interface
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (id: string) => Effect.Effect<User, NotFoundError>
readonly save: (user: User) => Effect.Effect<void, DatabaseError>
}
>() {}
class EmailService extends Context.Tag("EmailService")<
EmailService,
{
readonly send: (to: string, subject: string, body: string) =>
Effect.Effect<void, EmailError>
}
>() {}
// Use services in your program
const registerUser = (data: RegistrationData) =>
Effect.gen(function* () {
const repo = yield* UserRepository
const email = yield* EmailService
const user = createUser(data)
yield* repo.save(user)
yield* email.send(
user.email,
"Welcome!",
`Hello ${user.name}, your account is ready.`
)
return user
})
// Type: Effect<User, DatabaseError | EmailError, UserRepository | EmailService>
The Requirements channel (the third type parameter) lists UserRepository | EmailService. You cannot run this effect until both services are provided. Layers are the mechanism for providing them.
// Production layer backed by PostgreSQL
const PostgresUserRepo = Layer.succeed(
UserRepository,
{
findById: (id) =>
Effect.tryPromise({
try: () => db.query("SELECT * FROM users WHERE id = $1", [id]),
catch: () => new NotFoundError(id)
}),
save: (user) =>
Effect.tryPromise({
try: () => db.query("INSERT INTO users ...", [user]),
catch: (e) => new DatabaseError(String(e))
})
}
)
// Test layer with in-memory storage
const InMemoryUserRepo = Layer.succeed(
UserRepository,
{
findById: (id) =>
Effect.fromNullable(users.get(id)).pipe(
Effect.mapError(() => new NotFoundError(id))
),
save: (user) =>
Effect.sync(() => { users.set(user.id, user) })
}
)
// Compose layers and run
const MainLayer = Layer.merge(PostgresUserRepo, SmtpEmailService)
Effect.runPromise(
registerUser(data).pipe(Effect.provide(MainLayer))
)
Layers compose horizontally with Layer.merge and vertically with Layer.provide when one layer depends on another. The compiler verifies the entire dependency graph at build time. If you forget to provide a service, you get a type error, not a runtime exception. This pattern works exceptionally well alongside modern runtimes like Bun in production where fast startup times make the Layer initialization overhead negligible.
Resource Management and Scoping
Managing resources that require cleanup, such as database connections, file handles, and network sockets, is notoriously error-prone. Effect provides the Scope mechanism to guarantee that resources are released even when errors or interruptions occur.
import { Effect, Scope } from "effect"
const acquireDbConnection = Effect.acquireRelease(
// Acquire: open the connection
Effect.tryPromise({
try: () => pool.connect(),
catch: (e) => new DatabaseError(`Connection failed: ${e}`)
}),
// Release: always close, even on failure
(connection) => Effect.sync(() => connection.release())
)
const withTransaction = <A, E>(
operation: (conn: PoolClient) => Effect.Effect<A, E>
) =>
Effect.scoped(
Effect.gen(function* () {
const conn = yield* acquireDbConnection
yield* Effect.tryPromise({
try: () => conn.query("BEGIN"),
catch: (e) => new DatabaseError(String(e))
})
const result = yield* operation(conn).pipe(
Effect.tapError(() =>
Effect.tryPromise({
try: () => conn.query("ROLLBACK"),
catch: () => new DatabaseError("Rollback failed")
})
)
)
yield* Effect.tryPromise({
try: () => conn.query("COMMIT"),
catch: (e) => new DatabaseError(String(e))
})
return result
})
)
The Effect.scoped call creates a scope boundary. Every resource acquired within that scope is guaranteed to be released when the scope closes, regardless of whether the operation succeeds, fails, or is interrupted. This is more reliable than try/finally because it handles asynchronous cleanup and concurrent resource management correctly.
You can nest scopes and compose scoped effects freely. The runtime manages the resource lifecycle, closing resources in reverse acquisition order, which prevents the subtle bugs that arise when cleanup code depends on other resources that may have already been closed.
Structured Concurrency
Effect provides structured concurrency primitives that guarantee child fibers (lightweight threads) are properly supervised. Unlike raw Promise.all, Effect's concurrency model ensures that if one fiber fails, sibling fibers are interrupted and their resources cleaned up.
import { Effect } from "effect"
// Run effects concurrently, fail fast on any error
const loadDashboard = Effect.all(
[fetchUserProfile, fetchRecentOrders, fetchNotifications],
{ concurrency: "unbounded" }
)
// Run with bounded concurrency (e.g., rate-limited API)
const enrichAllUsers = (userIds: string[]) =>
Effect.forEach(
userIds,
(id) => fetchAndEnrichUser(id),
{ concurrency: 10 }
)
// Race two effects, return the winner
const fetchWithFallback = Effect.race(
fetchFromPrimary,
fetchFromSecondary.pipe(Effect.delay("200 millis"))
)
// Fork a background fiber
const program = Effect.gen(function* () {
const fiber = yield* Effect.fork(backgroundSync)
// Main work continues
const result = yield* mainOperation
// Wait for background work or interrupt it
yield* Fiber.interrupt(fiber)
return result
})
Structured concurrency prevents fire-and-forget patterns that leak resources. Every fiber exists within a scope, and the runtime ensures that parent fibers wait for their children before completing. This is especially important in server environments where leaked fibers accumulate over time and cause memory pressure.
The concurrency option on Effect.forEach and Effect.all provides a declarative way to control parallelism. You describe what should run concurrently, and the runtime handles scheduling, error propagation, and interruption. This complements cross-language interop patterns described in the WebAssembly Component Model guide, where concurrent host calls need careful lifecycle management.
Building Composable Pipelines
One of Effect's strengths is how naturally effects compose into pipelines. The pipe function and generator syntax (Effect.gen) let you build complex workflows from simple building blocks without losing type safety.
import { Effect, pipe, Schedule } from "effect"
// A composable middleware-style pipeline
const processOrder = (orderId: string) =>
pipe(
// Step 1: Validate
validateOrder(orderId),
// Step 2: Check inventory
Effect.flatMap((order) =>
checkInventory(order.items).pipe(
Effect.map((availability) => ({ order, availability }))
)
),
// Step 3: Reserve items
Effect.flatMap(({ order, availability }) =>
reserveItems(order, availability)
),
// Step 4: Charge payment with retry
Effect.flatMap((reservation) =>
chargePayment(reservation).pipe(
Effect.retry(
Schedule.exponential("500 millis").pipe(
Schedule.compose(Schedule.recurs(3))
)
)
)
),
// Step 5: Send confirmation
Effect.flatMap((payment) =>
sendConfirmation(payment).pipe(
Effect.catchTag("EmailError", () =>
Effect.logWarning("Confirmation email failed, order still processed")
)
)
),
// Cross-cutting: add logging and metrics
Effect.tap((result) =>
Effect.log(`Order ${orderId} processed successfully`)
),
Effect.withSpan("processOrder", { attributes: { orderId } })
)
Each step in the pipeline adds its errors to the error channel and its requirements to the requirements channel. The final effect type reflects every possible failure and every required service across the entire pipeline. This compositional property means you can build, test, and reuse individual steps independently.
The generator syntax offers an alternative that reads more like imperative code while preserving all the same type-level guarantees.
const processOrderGen = (orderId: string) =>
Effect.gen(function* () {
const order = yield* validateOrder(orderId)
const availability = yield* checkInventory(order.items)
const reservation = yield* reserveItems(order, availability)
const payment = yield* Effect.retry(
chargePayment(reservation),
Schedule.exponential("500 millis").pipe(
Schedule.compose(Schedule.recurs(3))
)
)
yield* sendConfirmation(payment).pipe(
Effect.catchTag("EmailError", () =>
Effect.logWarning("Confirmation email failed")
)
)
yield* Effect.log(`Order ${orderId} processed`)
return payment
}).pipe(
Effect.withSpan("processOrder", { attributes: { orderId } })
)
Testing Effect Programs
The Layer system makes testing Effect programs straightforward. You swap production layers for test layers without changing any business logic. Because dependencies are tracked in the type system, the compiler tells you exactly which services each test needs to provide.
import { it, expect, describe } from "vitest"
import { Effect, Layer } from "effect"
// Test doubles
const TestUserRepo = Layer.succeed(UserRepository, {
findById: (id) =>
id === "user-1"
? Effect.succeed({ id: "user-1", name: "Test User", email: "[email protected]" })
: Effect.fail(new NotFoundError(id)),
save: (user) => Effect.sync(() => { savedUsers.push(user) })
})
const TestEmailService = Layer.succeed(EmailService, {
send: (to, subject, body) =>
Effect.sync(() => { sentEmails.push({ to, subject, body }) })
})
const TestLayer = Layer.merge(TestUserRepo, TestEmailService)
describe("registerUser", () => {
it("saves user and sends welcome email", async () => {
const result = await Effect.runPromise(
registerUser({
name: "Alice",
email: "[email protected]",
password: "secure123"
}).pipe(Effect.provide(TestLayer))
)
expect(result.name).toBe("Alice")
expect(sentEmails).toHaveLength(1)
expect(sentEmails[0].subject).toBe("Welcome!")
expect(savedUsers).toHaveLength(1)
})
it("propagates database errors", async () => {
const FailingRepo = Layer.succeed(UserRepository, {
findById: () => Effect.fail(new NotFoundError("any")),
save: () => Effect.fail(new DatabaseError("Connection lost"))
})
const result = await Effect.runPromiseExit(
registerUser(validData).pipe(
Effect.provide(Layer.merge(FailingRepo, TestEmailService))
)
)
expect(Exit.isFailure(result)).toBe(true)
})
})
Because layers are values, you can create test fixtures that combine different service implementations for various test scenarios. There is no need for mocking libraries or reflection-based injection. The test code is plain TypeScript, fully type-checked, and easy to maintain.
This testing approach pairs well with comprehensive testing frameworks. For teams running large test suites, the patterns described in our Playwright testing architecture guide apply equally to structuring Effect-based integration tests with fixtures and parallel execution.
Adopting Effect Incrementally
You do not need to rewrite your entire application to benefit from Effect. The library provides interop functions that let you wrap existing Promise-based code and gradually migrate toward fully typed effects.
// Wrap an existing async function
const legacyFetch = (url: string): Promise<Response> =>
fetch(url)
const effectFetch = (url: string) =>
Effect.tryPromise({
try: () => legacyFetch(url),
catch: (error) => new NetworkError(String(error))
})
// Convert an Effect back to Promise for existing code
const existingHandler = async (req: Request) => {
const result = await Effect.runPromise(
processRequest(req).pipe(Effect.provide(ProductionLayer))
)
return new Response(JSON.stringify(result))
}
// Use Effect in Express middleware
app.get("/api/users/:id", async (req, res) => {
const exit = await Effect.runPromiseExit(
fetchUserProfile(req.params.id).pipe(
Effect.provide(ProductionLayer)
)
)
Exit.match(exit, {
onFailure: (cause) => {
const error = Cause.failureOption(cause)
if (Option.isSome(error)) {
res.status(error.value.statusCode).json({
error: error.value.message
})
} else {
res.status(500).json({ error: "Internal server error" })
}
},
onSuccess: (user) => res.json(user)
})
})
Start with a single module or service boundary. Wrap external calls in Effect, define your error types, and gradually expand the boundary as you gain confidence. The interop layer means your Effect code can coexist with existing Promise-based code indefinitely. Over time, as more of your codebase adopts Effect, you gain increasingly comprehensive type-level guarantees about your application's behavior.
The key decision is where to draw the Effect boundary. A practical approach is to start at the service layer, where business logic lives, and let the HTTP/transport layer remain in its existing form. This gives you the most value with the least disruption, and the explicit error types in your service layer naturally improve the quality of your API error responses.