neverthrow vs Effect vs oxide.ts: Result Types in TypeScript (2026)
TL;DR
neverthrow is a lightweight Result type for TypeScript — Ok<T> / Err<E> with .map(), .andThen(), and ResultAsync for promise chains. Effect is a full-featured effect system — handles errors, dependency injection, concurrency, retries, and observability in one framework. oxide.ts is the minimal Rust-inspired option — Result<T, E> and Option<T> with familiar .unwrap(), .match(), and .mapErr(). In 2026: neverthrow for adding typed errors to existing TypeScript projects, Effect for large-scale applications, oxide.ts for Rust developers who want familiar patterns.
Key Takeaways
- neverthrow: ~500K weekly downloads — lightweight,
Ok/Errresult type,ResultAsync,safeTry - Effect (effect-ts): ~300K weekly downloads — comprehensive effect system, far beyond just Result types
- oxide.ts: ~20K weekly downloads — Rust-inspired
Result+Option, minimal API - The core idea: make errors visible in the type signature instead of hiding them in
throw function getPackage(name: string): Result<Package, NotFoundError | DbError>— caller SEES the possible errors- neverthrow is the sweet spot — adds typed errors without rewriting your entire codebase
Why Result Types?
// ❌ Traditional: errors are invisible in the type signature:
async function getPackage(name: string): Promise<Package> {
// Can throw: NotFoundError, DatabaseError, NetworkError, ValidationError
// Caller has NO idea which errors to handle
// TypeScript says return type is Package — lies!
}
// ✅ Result type: errors are part of the type:
async function getPackage(name: string): ResultAsync<Package, NotFoundError | DatabaseError> {
// Caller sees EXACTLY which errors can occur
// TypeScript enforces handling them
}
neverthrow
neverthrow — lightweight Result type:
Basic Result
import { ok, err, Result } from "neverthrow"
// Define error types:
class NotFoundError {
readonly _tag = "NotFoundError"
constructor(readonly resource: string, readonly id: string) {}
}
class ValidationError {
readonly _tag = "ValidationError"
constructor(readonly field: string, readonly message: string) {}
}
// Return Result instead of throwing:
function getPackage(name: string): Result<Package, NotFoundError> {
const pkg = db.packages.find(p => p.name === name)
if (!pkg) {
return err(new NotFoundError("Package", name))
}
return ok(pkg)
}
// Caller MUST handle both cases:
const result = getPackage("react")
if (result.isOk()) {
console.log(result.value.name) // TypeScript knows: Package
} else {
console.error(result.error) // TypeScript knows: NotFoundError
}
Chaining with .map() and .andThen()
import { ok, err, Result } from "neverthrow"
function validateName(name: string): Result<string, ValidationError> {
if (name.length < 2) return err(new ValidationError("name", "Too short"))
if (!/^[a-z0-9@/_-]+$/.test(name)) return err(new ValidationError("name", "Invalid chars"))
return ok(name)
}
function getPackage(name: string): Result<Package, NotFoundError> {
const pkg = db.packages.find(p => p.name === name)
return pkg ? ok(pkg) : err(new NotFoundError("Package", name))
}
function calculateScore(pkg: Package): Result<number, ValidationError> {
if (!pkg.downloads) return err(new ValidationError("downloads", "Missing data"))
return ok(pkg.downloads / 1000 + pkg.stars / 100)
}
// Chain operations — stops at first error:
const result = validateName("react")
.andThen(getPackage) // Only runs if validateName succeeded
.andThen(calculateScore) // Only runs if getPackage succeeded
.map(score => Math.round(score)) // Transform success value
// result: Result<number, ValidationError | NotFoundError>
ResultAsync (async operations)
import { ResultAsync, okAsync, errAsync } from "neverthrow"
function fetchPackageData(name: string): ResultAsync<NpmData, NetworkError> {
return ResultAsync.fromPromise(
fetch(`https://registry.npmjs.org/${name}`).then(r => r.json()),
(error) => new NetworkError(`Failed to fetch ${name}: ${error}`)
)
}
function fetchGithubStars(repo: string): ResultAsync<number, NetworkError> {
return ResultAsync.fromPromise(
fetch(`https://api.github.com/repos/${repo}`).then(r => r.json()).then(d => d.stargazers_count),
(error) => new NetworkError(`GitHub API failed: ${error}`)
)
}
// Chain async operations:
const result = await fetchPackageData("react")
.andThen(data => fetchGithubStars(data.repository))
.map(stars => ({ stars, rating: stars > 100000 ? "popular" : "growing" }))
if (result.isOk()) {
console.log(result.value) // { stars: 224000, rating: "popular" }
}
safeTry (generator-based, cleaner syntax)
import { safeTry, ok, err } from "neverthrow"
const result = safeTry(function* () {
// yield* unwraps Result — returns value or short-circuits on error:
const name = yield* validateName("react")
const pkg = yield* getPackage(name)
const score = yield* calculateScore(pkg)
return ok({ name, score })
})
// result: Result<{ name: string, score: number }, ValidationError | NotFoundError>
Effect
Effect — TypeScript effect system:
Basic Effect
import { Effect, pipe } from "effect"
class NotFoundError {
readonly _tag = "NotFoundError"
constructor(readonly name: string) {}
}
class DatabaseError {
readonly _tag = "DatabaseError"
constructor(readonly message: string) {}
}
// Effect<Success, Error, Requirements>
function getPackage(name: string): Effect.Effect<Package, NotFoundError | DatabaseError> {
return Effect.tryPromise({
try: () => db.packages.findFirst({ where: { name } }),
catch: (e) => new DatabaseError(String(e)),
}).pipe(
Effect.flatMap(pkg =>
pkg ? Effect.succeed(pkg) : Effect.fail(new NotFoundError(name))
)
)
}
// Run the effect:
const result = await Effect.runPromise(
getPackage("react").pipe(
Effect.catchTag("NotFoundError", (e) =>
Effect.succeed({ name: e.name, score: 0, notFound: true })
)
)
)
Pipe and composition
import { Effect, pipe } from "effect"
const program = pipe(
getPackage("react"),
Effect.flatMap(pkg => calculateHealthScore(pkg)),
Effect.map(score => ({ score, grade: score > 80 ? "A" : "B" })),
Effect.retry({ times: 3 }), // Built-in retry
Effect.timeout("5 seconds"), // Built-in timeout
Effect.tap(result => Effect.log(`Score: ${result.score}`)), // Built-in logging
)
// Effect handles: errors, retries, timeouts, logging — all composable
await Effect.runPromise(program)
Dependency injection (Effect's power)
import { Effect, Context, Layer } from "effect"
// Define service interfaces:
class PackageRepo extends Context.Tag("PackageRepo")<
PackageRepo,
{
findByName: (name: string) => Effect.Effect<Package, NotFoundError>
}
>() {}
class HealthCalculator extends Context.Tag("HealthCalculator")<
HealthCalculator,
{
compute: (pkg: Package) => Effect.Effect<number>
}
>() {}
// Use services:
const getHealthScore = (name: string) =>
Effect.gen(function* () {
const repo = yield* PackageRepo
const calc = yield* HealthCalculator
const pkg = yield* repo.findByName(name)
const score = yield* calc.compute(pkg)
return score
})
// Provide implementations:
const RepoLive = Layer.succeed(PackageRepo, {
findByName: (name) => Effect.tryPromise(() => db.packages.findFirst({ where: { name } })),
})
const CalcLive = Layer.succeed(HealthCalculator, {
compute: (pkg) => Effect.succeed(pkg.downloads / 1000),
})
// Run with real implementations:
await Effect.runPromise(
getHealthScore("react").pipe(Effect.provide(Layer.merge(RepoLive, CalcLive)))
)
oxide.ts
oxide.ts — Rust-inspired Result + Option:
Result
import { Result, Ok, Err } from "oxide.ts"
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return Err("Division by zero")
return Ok(a / b)
}
const result = divide(10, 3)
// Pattern matching:
const output = result.match({
Ok: (value) => `Result: ${value.toFixed(2)}`,
Err: (error) => `Error: ${error}`,
})
// Unwrap (throws if Err — use carefully):
const value = result.unwrap() // 3.333... or throws
const safe = result.unwrapOr(0) // 3.333... or 0
const lazy = result.unwrapOrElse(() => computeDefault())
Option
import { Option, Some, None } from "oxide.ts"
function findPackage(name: string): Option<Package> {
const pkg = db.packages.find(p => p.name === name)
return pkg ? Some(pkg) : None
}
const pkg = findPackage("react")
if (pkg.isSome()) {
console.log(pkg.unwrap().name) // "react"
}
// Chaining:
const score = findPackage("react")
.map(pkg => pkg.healthScore)
.filter(score => score > 50)
.unwrapOr(0)
Chaining
import { Result, Ok, Err } from "oxide.ts"
const result = validateName("react")
.andThen(name => getPackage(name))
.map(pkg => pkg.healthScore)
.mapErr(err => `Failed: ${err}`)
// result: Result<number, string>
Feature Comparison
| Feature | neverthrow | Effect | oxide.ts |
|---|---|---|---|
| Result type | ✅ | ✅ | ✅ |
| Option type | ❌ | ✅ | ✅ |
| Async support | ✅ (ResultAsync) | ✅ (native) | ❌ |
| Dependency injection | ❌ | ✅ | ❌ |
| Retry / timeout | ❌ | ✅ | ❌ |
| Concurrency control | ❌ | ✅ | ❌ |
| Logging / tracing | ❌ | ✅ | ❌ |
| Pattern matching | ✅ (.match) | ✅ | ✅ (.match) |
| Learning curve | Low | High | Low |
| Bundle size | ~3 KB | ~50 KB+ | ~2 KB |
| Weekly downloads | ~500K | ~300K | ~20K |
When to Use Each
Choose neverthrow if:
- Want to add typed errors to an existing TypeScript project incrementally
- Need lightweight
Result<T, E>without a large framework ResultAsynccovers your async needs- Low learning curve — team can adopt in an afternoon
Choose Effect if:
- Building a large-scale application from scratch
- Need dependency injection, retries, concurrency, and observability built in
- Want a comprehensive effect system (not just error handling)
- Team is willing to invest in learning Effect's concepts
Choose oxide.ts if:
- Coming from Rust and want familiar
Result/Optionpatterns - Need
Option<T>type (neverthrow doesn't have it) - Want the smallest possible library (~2 KB)
- Simple use cases without async chaining
Keep using try/catch if:
- Errors are truly exceptional (crash-worthy)
- Team isn't ready for functional error handling
- Codebase is small and error handling is straightforward
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on neverthrow v8.x, effect v3.x, and oxide.ts v1.x.
Compare TypeScript utility and error handling packages on PkgPulse →