Web Development · TypeScript
Effect TS: Typed Error Handling in TypeScript That Actually Scales
JavaScript error handling is broken by default — exceptions are untyped, async errors are easy to miss, and side effects are invisible in function signatures. Effect TS fixes all three. Here is how it works.
Anurag Verma
7 min read
Sponsored
TypeScript adds types to JavaScript values but leaves one major blind spot: errors. A function that throws doesn’t communicate what it throws in its type signature. A Promise that rejects doesn’t tell callers what kind of rejection to expect. You write TypeScript to eliminate surprises, and then the error path is a complete surprise every time.
Effect TS addresses this at the type level. It’s a library that models effects — things that can fail, need dependencies, run asynchronously — as typed values you compose rather than side effects that happen around your code.
This is a different programming model from standard TypeScript. The payoff is substantial for the right codebase. Here is what it does and whether it fits yours.
The Core Problem
Standard TypeScript:
// What does this throw? The type signature doesn't say.
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
// The caller has no type-level information about what might go wrong
try {
const user = await fetchUser('123')
} catch (error) {
// error is `unknown` — you have to cast or check manually
if (error instanceof Error) {
console.error(error.message)
}
}
The error path is untyped. The catch block gets unknown. Callers who skip the try/catch let the error propagate silently. In a chain of async calls, the error origin is often ambiguous.
The Effect Type
Effect models computations as Effect<Value, Error, Requirements>:
Value— what the computation produces when it succeedsError— the union of all typed errors it can fail withRequirements— services the computation needs from its environment (dependency injection)
import { Effect } from 'effect'
function fetchUser(id: string): Effect.Effect<User, NetworkError | NotFoundError, never> {
return Effect.tryPromise({
try: async () => {
const response = await fetch(`/api/users/${id}`)
if (response.status === 404) throw new NotFoundError(id)
if (!response.ok) throw new NetworkError(response.status)
return response.json() as User
},
catch: (error) => {
if (error instanceof NotFoundError) return error
if (error instanceof NetworkError) return error
return new NetworkError(500)
},
})
}
Now the compiler knows this computation can fail with NetworkError | NotFoundError. Callers who compose with this effect see those errors in the type and have to handle them explicitly.
Error Classes
Define errors as classes so they’re distinguishable at the type level:
class NetworkError {
readonly _tag = 'NetworkError'
constructor(readonly statusCode: number) {}
}
class NotFoundError {
readonly _tag = 'NotFoundError'
constructor(readonly id: string) {}
}
class ValidationError {
readonly _tag = 'ValidationError'
constructor(readonly field: string, readonly message: string) {}
}
The _tag field is a discriminant that lets Effect (and TypeScript) narrow the type in error handling branches.
Composing Effects
The power of Effect is composition. pipe chains effects where each step can add to the success type or the error union:
import { Effect, pipe } from 'effect'
function getActiveUserOrders(userId: string) {
return pipe(
fetchUser(userId),
// Errors from fetchUser propagate forward
Effect.flatMap((user) => {
if (!user.isActive) {
return Effect.fail(new InactiveUserError(userId))
}
return fetchOrders(user.id)
}),
// Return type is now inferred as:
// Effect<Order[], NetworkError | NotFoundError | InactiveUserError | OrderFetchError, never>
Effect.flatMap((orders) =>
Effect.succeed(orders.filter(o => o.status === 'active'))
),
)
}
The return type of getActiveUserOrders is automatically inferred as the union of all errors across the chain. No manual union typing required.
Handling Errors
Effect provides typed error handling that exhausts the error union:
const result = pipe(
getActiveUserOrders(userId),
Effect.catchTag('NotFoundError', (_error) =>
// _error is narrowed to NotFoundError here
Effect.succeed([]) // Return empty array for missing users
),
Effect.catchTag('InactiveUserError', (error) =>
Effect.fail(new UserAccessError(`User ${error.userId} is not active`))
),
// After handling NotFoundError and InactiveUserError,
// remaining errors are NetworkError | OrderFetchError
Effect.retry({ times: 3 }),
)
After handling NotFoundError and InactiveUserError, the compiler knows only NetworkError | OrderFetchError remain. If you try to handle a tag that doesn’t exist in the error union, TypeScript catches it at compile time.
Dependency Injection via the Requirements Channel
The third type parameter enables compile-time dependency injection:
import { Effect, Context } from 'effect'
// Define a service interface
class Database extends Context.Tag('Database')<
Database,
{ query: (sql: string) => Promise<unknown[]> }
>() {}
// This function requires Database — visible in the type signature
function getUsers(): Effect.Effect<User[], DatabaseError, Database> {
return Effect.flatMap(Database, (db) =>
Effect.tryPromise({
try: () => db.query('SELECT * FROM users') as Promise<User[]>,
catch: () => new DatabaseError(),
})
)
}
// Provide the real implementation at the program boundary
const runnable = pipe(
getUsers(),
Effect.provideService(Database, {
query: (sql) => pool.query(sql).then(r => r.rows),
})
)
// Now Requirements is `never` — the effect can run
Effect.runPromise(runnable)
In tests, swap in a mock without patching imports or mocking frameworks:
const testRunnable = pipe(
getUsers(),
Effect.provideService(Database, {
query: () => Promise.resolve([{ id: '1', name: 'Alice' }]),
})
)
The dependency is injected through the type system. The test fails at compile time if you forget to provide a required service.
What Else Effect Includes
Effect isn’t just typed errors. The library includes:
Structured concurrency: Effect.all runs effects in parallel with automatic cancellation if one fails. Effect.race returns the first result. Fibers provide lightweight concurrency without callback nesting.
Resource management: Effect.acquireRelease guarantees cleanup even when effects fail or are interrupted — similar to Python’s with or Rust’s Drop, but composable with the rest of the effect system.
Retries and scheduling: Effect.retry with configurable exponential backoff, Effect.timeout, Effect.race with a deadline — all typed and composable.
Schema: @effect/schema provides runtime validation that integrates with the Effect type system, so parse errors are typed failures in the error channel.
When Effect Is Worth the Learning Curve
Effect has a steep learning curve. The pipe-based style and the three-channel type are unfamiliar to most TypeScript developers. Bringing Effect into a codebase means the team needs to learn it, and partial adoption — some files using Effect, others not — creates friction at the boundaries.
Effect pays off when:
- Error handling is currently a mess: functions that throw anything, catch blocks that swallow errors, inconsistent error types across modules
- Dependency injection is complex: services with multiple implementations (test vs production), initialization order matters, cleanup needs to be reliable
- Concurrency is significant: parallel requests with cancellation, rate limiting, resource pools
- The codebase is large enough that compile-time guarantees have meaningful return on the learning investment
Effect is probably not worth it for a small API with five endpoints, a CLI tool, or a frontend application. It’s worth serious consideration for backend services that handle complex business logic with multiple failure modes and external service dependencies.
Getting Started Without Rewriting Everything
You don’t need to convert an entire codebase. Effect composes with standard TypeScript at the edges:
// Existing Express handler calling new Effect-based code
async function existingHandler(req: Request, res: Response) {
try {
const result = await Effect.runPromise(
pipe(
getActiveUserOrders(req.params.userId),
Effect.catchAll((error) => {
if (error._tag === 'NotFoundError') return Effect.succeed([])
return Effect.fail(error)
})
)
)
res.json(result)
} catch (error) {
res.status(500).json({ error: 'Internal server error' })
}
}
Effect.runPromise bridges back to standard Promises. Introduce Effect in new modules first, handle the conversion at the boundary, and expand as the team gets comfortable with the model.
Who Is Actually Using It
Effect has moved from a niche functional-programming library to production use at a number of backend-heavy TypeScript shops. The community has grown substantially since 2023, and the documentation is now thorough enough that getting started doesn’t require reading academic papers.
The problem it solves — untyped errors in TypeScript — is real and felt by anyone who has debugged a production incident caused by an unhandled promise rejection or a thrown value that wasn’t an Error. TypeScript should have had typed error channels from the start. It didn’t, but Effect gets you there today.
Sponsored
More from this category
More from Web Development
OAuth 2.0 and PKCE: The Web Auth Patterns Every SPA Developer Needs in 2026
Technical SEO for JavaScript Apps in 2026: What Google Actually Renders
Progressive Web Apps in 2026: What Actually Works on iOS and Android
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored