TypeScript developers juggle 4–5 libraries to handle what Effect does in one: validation (Zod), error handling (neverthrow/ts-results), retry logic (p-retry), dependency injection (tsyringe), and async concurrency (p-limit). Effect unifies all of them.
Here's what it looks like in practice.
The Problem With the Current Stack
A typical production handler:
// validation
const input = UserSchema.parse(req.body) // throws on error
// async with retry
const user = await retry(async () => db.users.findById(input.id), { retries: 3 })
// result type
const result: Result<User, DbError> = ...
// error boundary
try {
...
} catch (e) {
if (e instanceof ValidationError) { ... }
else if (e instanceof NetworkError) { ... }
}
Every library has its own error model. They don't compose.
Effect's Core Model
Effect uses a three-type signature: Effect<Success, Error, Requirements>.
import { Effect, Schema } from "effect"
const UserSchema = Schema.Struct({
id: Schema.String,
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/))
})
type User = Schema.Schema.Type<typeof UserSchema>
const getUser = (id: string): Effect.Effect<User, UserNotFoundError | DbError> =>
Effect.tryPromise({
try: () => db.users.findById(id),
catch: (e) => new DbError({ cause: e })
}).pipe(
Effect.flatMap(user =>
user ? Effect.succeed(user) : Effect.fail(new UserNotFoundError({ id }))
)
)
The return type tells you exactly what can go wrong. No surprise unknown in catch blocks.
Error Handling That Composes
import { Effect, Data } from "effect"
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{ id: string }> {}
class DbError extends Data.TaggedError("DbError")<{ cause: unknown }> {}
const handleRequest = (id: string) =>
getUser(id).pipe(
Effect.catchTag("UserNotFoundError", (e) =>
Effect.succeed({ error: `User ${e.id} not found`, status: 404 })
),
Effect.catchTag("DbError", (e) =>
Effect.fail(new ServiceUnavailableError({ cause: e.cause }))
)
)
Pattern-matched error handling. TypeScript enforces exhaustiveness — if you add a new error type, the compiler tells you every unhandled site.
Built-In Retry and Schedule
import { Effect, Schedule } from "effect"
const withRetry = getUser(id).pipe(
Effect.retry(
Schedule.exponential("100 millis").pipe(
Schedule.compose(Schedule.recurs(3))
)
)
)
No p-retry. No async-retry. Schedule is composable — jitter, exponential backoff, max attempts, all chainable.
Dependency Injection Without a Framework
import { Effect, Context, Layer } from "effect"
interface Database {
readonly findUser: (id: string) => Promise<User | null>
}
const Database = Context.GenericTag<Database>("Database")
const getUser = (id: string) =>
Database.pipe(
Effect.flatMap(db => Effect.tryPromise(() => db.findUser(id)))
)
// Production layer
const ProdDatabase = Layer.succeed(Database, {
findUser: (id) => prodDb.users.findById(id)
})
// Test layer — swap without touching business logic
const TestDatabase = Layer.succeed(Database, {
findUser: (id) => Promise.resolve(mockUsers[id] ?? null)
})
// Run with a specific layer
Effect.runPromise(
getUser("123").pipe(Effect.provide(ProdDatabase))
)
No IoC container. No decorators. Pure TypeScript.
Validation With Schema
Effect ships its own schema library that replaces Zod:
import { Schema, ParseResult } from "effect"
const UserInput = Schema.Struct({
email: Schema.String.pipe(Schema.minLength(5), Schema.pattern(/.+@.+/)),
age: Schema.Number.pipe(Schema.int(), Schema.between(0, 150)),
role: Schema.Literal("admin", "user", "viewer")
})
// Decode returns an Effect, not a throw
const decoded = Schema.decodeUnknown(UserInput)(req.body)
// Error messages are structured, not thrown
decoded.pipe(
Effect.catchAll(e => Effect.succeed({ error: ParseResult.TreeFormatter.formatError(e) }))
)
When to Reach for Effect
Worth it when:
- You're building long-running services with complex error domains
- Multiple async operations need composable retry/timeout/concurrency
- You want compile-time guarantees on error handling exhaustiveness
Probably overkill when:
- Simple CRUD endpoints with 1–2 error cases
- Team isn't already comfortable with functional TypeScript
- Greenfield app where Zod + try/catch is sufficient
The learning curve is real. The payoff is a codebase where error paths are as visible as happy paths.
Want a production-ready TypeScript SaaS foundation with proper error handling baked in? The AI SaaS Starter Kit ships with typed error boundaries, Stripe billing, and AI integration — skip 80 hours of boilerplate setup.
Top comments (0)