Effect-TS vs neverthrow vs ts-results: Best Error Handling in TypeScript (2026)
try/catch loses type information. Your function signature says it returns User but it might throw NetworkError, ValidationError, or AuthError — and TypeScript won't tell you which. Result types fix this.
Three libraries lead the TypeScript error handling space: Effect-TS (full ecosystem), neverthrow (focused), and ts-results (minimal). Here's how to choose.
Quick Comparison
| Feature | Effect-TS | neverthrow | ts-results |
|---|---|---|---|
| Scope | Full platform (errors, concurrency, DI, etc.) | Error handling only | Error handling only |
| Result type | Effect<A, E, R> | Result<T, E> | Result<T, E> |
| Learning curve | Steep | Low | Minimal |
| Bundle size | ~50KB+ | ~5KB | ~2KB |
| Chaining | .pipe() | .andThen(), .map() | .map(), .andThen() |
| Async support | Built-in (fibers) | ResultAsync<T, E> | Manual |
| Dependency injection | Built-in (layers) | No | No |
| Concurrency | Built-in (fibers, semaphores) | No | No |
| Community | Growing fast | Moderate | Small |
Effect-TS: The Full Platform
Effect-TS isn't just error handling — it's a complete platform for building TypeScript applications with typed errors, dependency injection, concurrency, streaming, and more.
How It Works
import { Effect, pipe } from "effect"
const getUser = (id: string): Effect.Effect<User, NotFoundError | DbError> =>
pipe(
Effect.tryPromise({
try: () => db.users.findUnique({ where: { id } }),
catch: () => new DbError("Database connection failed"),
}),
Effect.flatMap((user) =>
user ? Effect.succeed(user) : Effect.fail(new NotFoundError(id))
)
)
// Caller knows exactly what errors are possible
const program = pipe(
getUser("123"),
Effect.catchTag("NotFoundError", (e) => Effect.succeed(defaultUser)),
// DbError still in the error channel — must be handled
)
Strengths
- Complete type safety. Errors, dependencies, and effects are all tracked in the type system.
- Dependency injection.
Rtype parameter tracks required services — compile-time DI. - Concurrency primitives. Fibers, semaphores, queues, and structured concurrency built-in.
- Composability. Effects compose cleanly. Build complex programs from small, testable pieces.
- Growing ecosystem. Schema validation, HTTP server, CLI parsing, SQL — all typed end-to-end.
- Production-tested. Used by companies with demanding reliability requirements.
Weaknesses
- Massive learning curve. This is a paradigm shift. Expect weeks to become productive.
- Large bundle. Tree-shaking helps, but it's still significant.
- Team adoption friction. Not everyone wants to learn functional programming concepts.
- Overkill for simple apps. If you just need Result types, Effect is like using a crane to hang a picture.
- Different mental model. Imperative code becomes declarative pipelines — not always intuitive.
Best For
Teams building complex, reliability-critical systems who are willing to invest in learning. Excellent for backend services, data pipelines, and applications with complex error handling requirements.
neverthrow: Focused Result Types
neverthrow does one thing well: typed Result<T, E> for synchronous code and ResultAsync<T, E> for async code. Nothing more, nothing less.
How It Works
import { ok, err, Result, ResultAsync } from "neverthrow"
const getUser = (id: string): ResultAsync<User, NotFoundError | DbError> =>
ResultAsync.fromPromise(
db.users.findUnique({ where: { id } }),
() => new DbError("Database connection failed")
).andThen((user) =>
user ? ok(user) : err(new NotFoundError(id))
)
// Caller knows exactly what errors are possible
const result = await getUser("123")
.map((user) => user.name)
.mapErr((error) => {
if (error instanceof NotFoundError) return "User not found"
return "Database error"
})
if (result.isOk()) {
console.log(result.value) // string
} else {
console.log(result.error) // string
}
Strengths
- Simple to learn. If you understand
Result<T, E>, you understand neverthrow. 30 minutes to be productive. - Small bundle. ~5KB. Negligible impact on your application.
- Great async support.
ResultAsyncmakes async error handling ergonomic. - Non-invasive. Add it to one function at a time. Coexists with try/catch code.
- Good documentation. Clear, concise docs with practical examples.
- Familiar API.
.map(),.andThen(),.mapErr()— standard Result type operations.
Weaknesses
- Error handling only. No DI, no concurrency, no effects — just results.
- No ecosystem. Unlike Effect, there's no schema library, HTTP framework, etc. built for neverthrow.
- Verbose chaining. Complex error handling chains can become hard to read.
- No pattern matching. TypeScript's discriminated unions work but aren't as ergonomic as pattern matching.
Best For
Teams that want typed error handling without a paradigm shift. The pragmatic choice for most TypeScript projects.
ts-results: Minimal and Rust-Inspired
ts-results is a minimal Result/Option implementation inspired by Rust. It's the smallest and simplest option.
How It Works
import { Ok, Err, Result } from "ts-results"
const getUser = (id: string): Result<User, string> => {
const user = db.users.findSync(id)
if (!user) return Err("User not found")
return Ok(user)
}
const result = getUser("123")
.map((user) => user.name)
if (result.ok) {
console.log(result.val) // string
} else {
console.log(result.val) // string (error)
}
Strengths
- Tiny. ~2KB. The smallest option.
- Rust-familiar API. If you know Rust's
Result<T, E>andOption<T>, you're immediately productive. - Simple. No framework, no ecosystem, just Result and Option types.
- Option type. Includes
Option<T>(Some/None) for nullable values — something neverthrow lacks.
Weaknesses
- No async support. You handle async yourself. This is a significant limitation for most web apps.
- Smaller community. Fewer contributors and less activity than neverthrow.
- Less ergonomic chaining. Fewer utility methods than neverthrow.
- Maintenance concerns. Less active development than the alternatives.
Best For
Small projects, Rust developers writing TypeScript, or teams that only need synchronous Result types.
Practical Comparison
Simple API Route
neverthrow:
const handler = async (req: Request) => {
const result = await validateInput(req.body)
.andThen((input) => getUser(input.userId))
.andThen((user) => updateProfile(user, input))
if (result.isOk()) {
return Response.json(result.value)
}
return Response.json({ error: result.error.message }, { status: 400 })
}
Effect-TS:
const handler = pipe(
validateInput(req.body),
Effect.flatMap((input) => getUser(input.userId)),
Effect.flatMap((user) => updateProfile(user, input)),
Effect.match({
onSuccess: (data) => Response.json(data),
onFailure: (error) => Response.json({ error: error.message }, { status: 400 }),
}),
Effect.runPromise,
)
Both achieve the same result. neverthrow is more familiar. Effect-TS scales better for complex logic.
Decision Framework
Choose Effect-TS When:
- You're building a complex backend with many error types and dependencies
- Your team is willing to invest weeks in learning
- You want DI, concurrency, and effects in one system
- Reliability and correctness are top priorities
- You're starting a new project (easier than retrofitting)
Choose neverthrow When:
- You want typed errors without changing how you write TypeScript
- You need async error handling (ResultAsync)
- You're adding result types to an existing codebase
- Your team ranges from junior to senior
- You want the best balance of power and simplicity
Choose ts-results When:
- You only need synchronous Result types
- You want the smallest possible library
- You're a Rust developer who wants familiar APIs
- Your project is small and won't grow much
FAQ
Can I use these with any framework?
Yes. All three are framework-agnostic. They work with Next.js, Express, Hono, NestJS, or any TypeScript code.
Do these work with existing try/catch code?
Yes. All provide utilities to wrap try/catch or Promise-based code into Result types. You can adopt incrementally.
Is Effect-TS worth the learning curve?
For complex backend systems: yes. For simple web apps: probably not. The value compounds as your system grows in complexity.
What about Zod for error handling?
Zod handles validation errors specifically. These libraries handle all types of errors. They complement each other — validate with Zod, wrap the result in a Result type.
The Verdict
- Effect-TS for complex systems where you want total type safety across errors, dependencies, and concurrency. High investment, high payoff.
- neverthrow for pragmatic typed error handling. Best balance of simplicity and power. The right choice for most teams.
- ts-results for minimal Result types in small or synchronous projects.
For most TypeScript projects in 2026, neverthrow is the sweet spot. Add it to your next project and stop losing error type information to try/catch.