How to Encode Error Information in Types for TypeScript Backends: A Practical Guide to Effect-TS
Every time you use the try { ... } catch (e: unknown) { ... } pattern, extracting e in the catch block requires a type assertion (as Error), and you can't tell from the function signature alone what errors might occur. Code that passes compilation but blows up at runtime — if you've run a TypeScript backend long enough, you've inevitably encountered this.
You can get by with a NestJS custom filter and Sentry combination, but as teams grow and codebases become more complex, the time spent reading implementation code just to understand "what errors can this function throw?" keeps increasing. When you push errors outside the type system, the compiler can't help you.
There's a spectrum of approaches to this problem. neverthrow is a lightweight option that can be partially adopted into existing codebases. It treats errors as values using the Result<T, E> type — low learning curve and easy to mix with existing async/await code. Effect-TS sits at the opposite end. It's a full-stack runtime that unifies not just error handling, but dependency injection, concurrency, retries, and streams into a single paradigm.
This article explains Effect-TS's core concepts — type-safe error handling, dependency injection via Context and Layer — and how to apply them to a real backend service. Code is based on v3.x and is aimed at those already running TypeScript backends.
Effect<A, E, R> — Three Type Parameters
Everything in Effect-TS starts from this type.
Effect<A, E, R>
// ↑ ↑ ↑
// │ │ └── Requirements: list of services (dependencies) needed for execution
// │ └───── Error: union of expected error types that can occur
// └──────── Success: return value type on successA conventional Promise<User> tells you nothing about what errors might occur internally.
// Conventional approach — no error info in the type
async function fetchUser(id: string): Promise<User> { ... }
// Effect approach — errors and dependencies are visible in the type
const fetchUser = (id: string): Effect.Effect<User, NotFoundError | DatabaseError, DatabaseService> => ...The two slots enforce different things.
- R slot: You cannot call
Effect.runPromiseunless all dependencies are provided until it becomesnever. This is a compile-time hard constraint. - E slot: Even with errors remaining, compilation passes and execution proceeds. However, unhandled errors become unhandled failures at runtime that propagate as Promise rejections. The value of the E slot lies less in compile-time enforcement and more in design pressure — it documents what errors can occur as types, and creates a structure that guides you to handle each one explicitly with
catchTag.
Expected Errors and Defects — Errors That Appear in the Type, and Those That Don't
Saying "all errors are captured in the type" is inaccurate. Effect distinguishes between two kinds of errors.
Expected Error: Anticipated failures in business logic. Created with Effect.fail and recorded in the E slot. Can be handled with catchTag.
Defect: Unexpected runtime errors. Either explicitly created with Effect.die, or occurring when an exception thrown by an external library goes uncaught. Not recorded in the E slot — can only be caught with Effect.sandbox or Effect.catchAllCause.
// Expected Error — recorded in E slot, handleable with catchTag
const findUser = (id: string) =>
id === "" ? Effect.fail(new NotFoundError({ id })) : Effect.succeed({ id, name: "Alice" })
// Type: Effect<{ id: string; name: string }, NotFoundError, never>
// Defect — NOT recorded in E slot
const badDivide = (a: number, b: number) =>
b === 0
? Effect.die("cannot divide by zero")
: Effect.succeed(a / b)
// Type: Effect<number, never, never> ← error not visible in E!
// Use tryPromise to convert external library throws to Expected Errors
const queryDb = (sql: string) =>
Effect.tryPromise({
try: () => pool.query(sql),
catch: (err) => new DatabaseError({ message: String(err) }), // recorded in E slot
})If you call an external library directly without Effect.tryPromise, its throws become Defects. To capture them as Expected Errors in the type, always wrap with Effect.tryPromise or Effect.try.
Typed Errors — Defining Errors as Values
Effect defines errors as Tagged Error classes, making them subject to type inference.
import { Data, Effect } from "effect"
class NotFoundError extends Data.TaggedError("NotFoundError")<{
id: string
}> {}
class DatabaseError extends Data.TaggedError("DatabaseError")<{
message: string
cause?: unknown
}> {}Data.TaggedError automatically attaches a _tag field so type narrowing works perfectly when branching with catchTag.
const findUser = (id: string) =>
Effect.gen(function* () {
const db = yield* DatabaseService
const row = yield* db.query(`SELECT * FROM users WHERE id = $1`, [id])
if (!row) {
return yield* Effect.fail(new NotFoundError({ id }))
}
return row as User
})
// Return type: Effect<User, NotFoundError | DatabaseError, DatabaseService>Inside Effect.gen, yield* behaves like await while automatically accumulating failure possibilities in the E slot. The error-handling side looks like this:
const result = yield* findUser("123").pipe(
Effect.catchTag("NotFoundError", (err) =>
Effect.succeed({ id: err.id, name: "Unknown" })
),
Effect.catchTag("DatabaseError", (err) => {
console.error(`DB error: ${err.message}`)
return Effect.fail(new ServiceUnavailableError())
})
)Each time you handle an error type with catchTag, that type is removed from the E slot. Once all errors are handled with catchTag, E becomes never, eliminating the risk of runtime unhandled failures.
Context & Layer — Type-Verified Dependency Injection
If you're familiar with NestJS's @Injectable() and DI containers, the Layer concept won't feel foreign. The difference is that dependency relationships are verified at the type level, not through runtime reflection.
Services are defined by extending Effect.Service (in v3 it's Effect.Service, not Context.Service).
import { Effect, Layer } from "effect"
class DatabaseService extends Effect.Service<DatabaseService>()(
"DatabaseService",
{
effect: Effect.gen(function* () {
// Default stub — must be replaced with PgDatabaseLayer in real apps
return {
query: (_sql: string, _params: unknown[]): Effect.Effect<unknown[], DatabaseError> =>
Effect.die("DatabaseService not provided"),
}
}),
}
) {}The actual implementation is separated into a Layer.effect. Dependency injection is handled with yield* inside the Layer, not in the service class definition.
// PostgreSQL implementation layer
const PgDatabaseLayer = Layer.effect(
DatabaseService,
Effect.gen(function* () {
const config = yield* AppConfig // declares in the type that this Layer depends on AppConfig
const pool = new Pool({ connectionString: config.databaseUrl })
return {
query: (sql: string, params: unknown[]) =>
Effect.tryPromise({
try: () => pool.query(sql, params),
catch: (err) => new DatabaseError({ message: String(err) }),
}),
}
})
)
// In-memory layer for testing
const InMemoryDatabaseLayer = Layer.succeed(DatabaseService, {
query: (_sql, _params) => Effect.succeed([{ id: "1", name: "test" }]),
})Composing layers expresses the dependency graph as types.
const AppLayer = Layer.mergeAll(
PgDatabaseLayer, // internally references AppConfig via yield*
HttpClientLayer,
LoggerLayer,
AppConfigLayer // the dependency that PgDatabaseLayer requires
)
Effect.runPromise(
mainProgram.pipe(Effect.provide(AppLayer))
)When testing, just swap in InMemoryDatabaseLayer. No DI container configuration, no Jest mock setup needed.
Effect.gen — Generators Like async/await
The function* and yield* syntax may feel unfamiliar, but the pattern is nearly identical to async/await.
// async/await version
async function createOrder(data: OrderData) {
const user = await getUser(data.userId)
const stock = await checkStock(data.itemId)
if (stock < data.quantity) throw new InsufficientStockError()
return await saveOrder({ user, ...data })
}
// Effect.gen version
const createOrder = (data: OrderData) =>
Effect.gen(function* () {
const user = yield* getUser(data.userId)
const stock = yield* checkStock(data.itemId)
if (stock < data.quantity) {
return yield* Effect.fail(new InsufficientStockError({ available: stock }))
}
return yield* saveOrder({ user, ...data })
})Each yield* behaves like await, and instead of throwing exceptions, failures add types to the E slot.
Real-World Application
Scenario 1 — Layering Errors in an HTTP Handler
This is the pattern using @effect/platform's HttpRouter. Handlers are Effects, and HttpRouter re-executes the Effect on each request while injecting HttpServerRequest into the context — the same way NestJS controller methods are called per request.
import {
HttpRouter, HttpServer, HttpServerRequest,
HttpServerResponse, HttpMiddleware
} from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Effect, Schema, Layer } from "effect"
import { createServer } from "node:http"
const CreateUserBody = Schema.Struct({
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
name: Schema.String.pipe(Schema.minLength(1)),
})
const createUserHandler = Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const body = yield* req.json.pipe(
Effect.flatMap(Schema.decodeUnknown(CreateUserBody)),
// Handle both JSON parse errors and schema validation errors as 400
// If you need different logging strategies for each case, separate them before mapError
Effect.mapError((err) => new ValidationError({ message: String(err) }))
)
const userService = yield* UserService
const user = yield* userService.create(body)
return HttpServerResponse.json(user, { status: 201 })
}).pipe(
Effect.catchTag("ValidationError", (err) =>
HttpServerResponse.json({ error: err.message }, { status: 400 })
),
Effect.catchTag("DuplicateEmailError", (_err) =>
HttpServerResponse.json({ error: "Email already in use." }, { status: 409 })
),
Effect.catchTag("DatabaseError", (err) =>
Effect.gen(function* () {
const logger = yield* Logger
yield* logger.error("DB error occurred", { cause: err.message })
return HttpServerResponse.json({ error: "Server error" }, { status: 500 })
})
)
)
// Register with router and run server
const router = HttpRouter.empty.pipe(
HttpRouter.post("/users", createUserHandler)
)
const app = router.pipe(HttpServer.serve(HttpMiddleware.logger))
const ServerLayer = NodeHttpServer.layer(() => createServer(), { port: 3000 })
NodeRuntime.runMain(
Layer.launch(Layer.provide(app, Layer.merge(ServerLayer, AppLayer)))
)Each time a catchTag is applied, the corresponding type is removed from the E slot. If unhandled error types remain, you can immediately see which errors are still unhandled from the type information.
Scenario 2 — Wrapping an External API Client as a Layer
This is the pattern for wrapping a client with potential failures, like an external payment API. The dependency (HttpClient) is injected with yield* inside the Live Layer, not in the service class definition.
import { Effect, Layer, Schedule, Data } from "effect"
import { HttpClient } from "@effect/platform"
class PaymentGatewayError extends Data.TaggedError("PaymentGatewayError")<{
statusCode: number
}> {}
class PaymentService extends Effect.Service<PaymentService>()(
"PaymentService",
{
effect: Effect.gen(function* () {
return {
charge: (_amount: number, _currency: string): Effect.Effect<ChargeResult, PaymentGatewayError> =>
Effect.die("PaymentService.Live was not provided"),
}
}),
}
) {
static readonly Live = Layer.effect(
PaymentService,
Effect.gen(function* () {
const httpClient = yield* HttpClient.HttpClient // injected at the Layer level
return {
charge: (amount: number, currency: string) =>
httpClient.post("https://api.payment.example/charge").pipe(
Effect.flatMap((res) =>
res.status >= 400
? Effect.fail(new PaymentGatewayError({ statusCode: res.status }))
: res.json // res.json is an Effect, so it chains naturally in flatMap
),
// Exponential backoff retry — expressed declaratively
Effect.retry(
Schedule.exponential("100 millis").pipe(
Schedule.intersect(Schedule.recurs(3))
)
)
),
}
})
)
}Schedule.exponential expresses exponential backoff retries declaratively in just two lines. Implementing this directly results in tangled setTimeout, loops, and counter variables.
Scenario 3 — Separating Environments with Layers
import { Either } from "effect"
const TestLayer = Layer.mergeAll(
InMemoryDatabaseLayer,
MockPaymentServiceLayer,
ConsoleLoggerLayer
)
const ProductionLayer = Layer.mergeAll(
PgDatabaseLayer,
PaymentService.Live,
StructuredLoggerLayer
)
// Test
describe("createOrder", () => {
it("returns InsufficientStockError when out of stock", async () => {
const result = await Effect.runPromise(
createOrder({ itemId: "item-1", quantity: 999 }).pipe(
Effect.provide(TestLayer),
Effect.either
)
)
// Type narrowing with Either.isLeft — type-safe verification without as any
expect(Either.isLeft(result)).toBe(true)
if (Either.isLeft(result)) {
expect(result.left._tag).toBe("InsufficientStockError")
}
})
})A single Effect.provide(TestLayer) replaces all dependencies. Since the goal of using Effect is type safety, test code also verifies errors type-safely through Either.isLeft narrowing, without as any.
Scenario 4 — Parallel Execution and Structured Concurrency
const getDashboardData = (userId: string) =>
Effect.gen(function* () {
const [orders, notifications, profile] = yield* Effect.all(
[
orderService.getRecent(userId),
notificationService.getUnread(userId),
userService.getProfile(userId),
],
{ concurrency: 3 }
)
return { orders, notifications, profile }
})Effect.all executes the Effects in the array in parallel and automatically cancels the rest if any one fails. With Promise.all, if one rejects, the remaining Promises continue running in the background. Effect's Fiber-based concurrency immediately cancels the rest and releases resources if one fails. This difference structurally eliminates sources of memory leaks and unexpected side effects.
Pros and Cons
Pros
| Item | Detail |
|---|---|
| Error type tracking | Possible expected errors are explicit in the E slot. You can tell from the type which errors remain unhandled |
| Explicit dependencies | What a function depends on is visible via the R slot. Missing dependencies are compile errors |
| Testability | Mocking is done simply by swapping layers. No DI container configuration needed |
| Structured concurrency | Fiber-based cancellation and resource release guaranteed |
| Declarative retries & schedules | Express complex retry logic concisely with Schedule |
| Built-in observability | Automatic span instrumentation with @effect/opentelemetry |
| Single ecosystem | Streams, caching, Schema, SQL clients all in one paradigm |
Cons
| Item | Detail |
|---|---|
| Viral | Once you use a function that returns an Effect, callers must also use Effect. Managing boundaries with existing codebases is complex |
| Learning curve | Fiber, Layer, Cause, Defect, Schedule, and Scope must all be learned at once. 3–6 weeks to recover team productivity |
| Ecosystem immaturity | Few Effect-native libraries mean interop code is needed when integrating external libraries |
| Bundle size | The extensive functionality means a large bundle. Best suited for server-side use |
| Code readability | The function* and yield* syntax requires team convention alignment |
Common Mistakes in Practice
1. Attempting partial adoption into an existing codebase
Inserting Effect only into parts of Express or NestJS controllers leads to repeatedly calling Effect.runPromise at boundary points. This breaks structured error tracking at the boundaries and creates cognitive load from alternating between async/await and Effect code. A strategy of inserting Effect incrementally into an existing codebase almost always comes back as interop costs. If you want partial adoption, neverthrow is the realistic choice.
2. Defining error types too broadly
Lumping DB connection failures, query timeouts, and unique constraint violations all into a single DatabaseError renders the E slot's information meaningless. You need to split them into distinguishable Tagged Errors for catchTag to work meaningfully.
3. Calling Effect.runPromise inside business logic
runPromise should only be called at the application entry point. Calling it internally breaks cancellation guarantees.
When to Adopt
Effect is closer to an "all-or-nothing" framework. The ROI is highest when building consistently from scratch on a new project, rather than incrementally integrating it into an existing codebase.
Closing Thoughts
Effect-TS addresses two problems. First, errors outside the type system mean the compiler can't tell what errors might occur. Second, implicit dependencies mean you have to read the implementation to know what's needed. Both are embraced by a single type: Effect<A, E, R>.
That said, it's worth being clear about one thing. "All errors are captured in the type" only applies to Expected Errors, and compilation passes even with errors remaining in the E slot. And a strategy of incrementally inserting Effect into an existing codebase almost always comes back as interop costs. Understanding these two points before adoption — versus going in with inflated expectations — makes a significant difference in team satisfaction.
If you're starting now, this order is realistic:
- Read the "Why Effect?" chapter in the official docs — understanding the philosophy first makes the API design intent click.
- Experiment with a new CLI tool or script first — in an environment isolated from your existing codebase, build a feel for
Effect.genandData.TaggedError. This stage is learning, not production integration. - Full adoption with
@effect/platformin a new microservice — building consistently from the HTTP handler down to the DB layer with Effect, having theEslot fill in automatically, answers why you'd choose this design.
A codebase that tracks errors as types without catch (e: unknown) is entirely achievable. Effect-TS is one of the most polished ways to get there.
References
- Effect Official Docs — Why Effect?
- Effect Official Docs — Expected Errors
- Effect Official Docs — Managing Layers
- Effect Official Docs — Fibers
- Effect Official Docs — Introduction to Effect Schema
- Effect GitHub Repository
- Effect-TS in 2026: Functional Programming for TypeScript That Actually Makes Sense
- Building a backend with Effect-TS: 6 patterns from Inkpipe
- Why We Love Functional Programming but Don't Use Effect-TS
- Effect vs fp-ts official comparison
- Error Handling in TypeScript: Neverthrow, Try-Catch, and EffectTS comparison
- EffectPatterns — community-driven pattern collection