Skip to main content

Guide

neverthrow vs Effect vs oxide.ts 2026

Compare neverthrow, Effect, and oxide.ts for Result and Option types in TypeScript. Railway-oriented programming, typed error handling without exceptions.

·PkgPulse Team·
0

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/Err result 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

FeatureneverthrowEffectoxide.ts
Result type
Option type
Async support✅ (ResultAsync)✅ (native)
Dependency injection
Retry / timeout
Concurrency control
Logging / tracing
Pattern matching✅ (.match)✅ (.match)
Learning curveLowHighLow
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
  • ResultAsync covers 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/Option patterns
  • 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

Incremental Adoption in Existing Codebases

One of neverthrow's strongest selling points is incremental adoption — you don't need to rewrite your entire codebase to start using Result types. The strategy is to add Result types at the boundary between your business logic and external systems (database calls, API requests, file operations) while leaving existing internal functions alone. Over time, the Result-returning functions propagate up the call stack naturally as callers discover that handling errors at the type level catches bugs early. Unlike Effect, which benefits most from system-wide adoption (its dependency injection and observability features require end-to-end integration), neverthrow provides value at the level of individual functions. Start with your most error-prone database query functions, add Result<Data, DbError> return types, and expand from there. This incremental path is why neverthrow's 500K downloads skew toward existing codebases while Effect's 300K reflect greenfield applications.

Effect's Dependency Injection vs Traditional DI

Effect's dependency injection system is architecturally unique: instead of class-based injection (NestJS style) or module-level singletons (most Node.js patterns), Effect uses a typed service registry where dependencies are tracked in the type signature as the third type parameter — Effect<Success, Error, Dependencies>. This means TypeScript catches missing dependencies at compile time rather than runtime — if you write an effect that uses DatabaseService but don't provide the implementation when running it, the compiler tells you. This is dramatically different from traditional IoC containers where dependency mismatches surface as runtime injection errors. The downside is that Effect's dependency injection requires understanding the Layer abstraction, which has a steeper learning curve than constructor injection. For teams already using a DI framework like inversify or NestJS, switching to Effect's DI system requires significant conceptual adjustment.

Comparison with Native Try-Catch and TypeScript Strictness

TypeScript's strict mode with useUnknownInCatchVariables (enabled in TypeScript 4.4+) makes catch clauses safer by typing the caught variable as unknown rather than any, requiring explicit type narrowing before accessing properties. This narrows the gap between traditional try-catch and Result types — at minimum, you can't accidentally access .message without checking the error type. However, this doesn't solve the core problem: the function's return type doesn't encode which errors it can throw, so callers still don't know what to expect. Result types make error flows as explicit in type signatures as success flows. The combination of useUnknownInCatchVariables plus Result types for domain-specific errors is increasingly common in 2026 TypeScript projects: use Result for expected domain errors (NotFound, ValidationError), use try-catch for truly exceptional errors (OOM, fatal crashes), and let TypeScript's strict mode ensure you handle the unknown catch cases.

Performance Overhead of Functional Error Handling

A practical concern for high-throughput Node.js services: does using Result types add meaningful CPU or memory overhead? In practice, no. Creating an ok(value) object allocates a small JavaScript object — negligible for any reasonable request volume. neverthrow's ResultAsync wraps a Promise with no additional layers beyond the Ok/Err wrapper that resolves or rejects. For comparison, Effect's overhead is more meaningful: it builds an effect graph that describes computations without executing them, adding a layer of abstraction that has measurable cost in microbenchmarks. For 99% of Node.js applications, this is irrelevant — the database query or HTTP call it wraps takes orders of magnitude longer than the Effect overhead. The one context where overhead matters is tight loops processing tens of thousands of items per second, where oxide.ts's minimal ~2KB implementation wins on allocation pressure.

Community and Ecosystem Trajectory

The Result type ecosystem is maturing rapidly. Effect's company (Effect Systems Inc.) raised funding and is building a commercial cloud platform around the Effect runtime, suggesting long-term investment in the ecosystem. neverthrow is community-maintained but stable — its API has been largely unchanged for several years, which is a feature rather than a bug for library consumers. oxide.ts is a focused single-author library that mirrors Rust's standard library faithfully — its primary audience is Rust developers coming to TypeScript who want familiar patterns. The broader ecosystem is converging: Zod v4 includes a safeParseAsync API that returns a Result-like object, many tRPC error handling patterns use Result-style discriminated unions, and the TC39 error handling proposal (still in early stages as of 2026) explores native Result types for JavaScript. Using neverthrow or oxide.ts now positions codebases to adopt whatever native solution eventually emerges.

Testing Strategies for Result-Based Code

Testing functions that return Result types requires slightly different patterns than testing throwing functions. With traditional throwing code, you use expect(() => fn()).toThrow(SomeError). With Result types, you assert on the returned Result object directly: expect(result.isOk()).toBe(true) and expect(result.isErr()).toBe(true). The advantage is that Result-based functions can be tested synchronously even when they wrap async operations via ResultAsync — you await the result and then assert on the Ok/Err value without try-catch wrappers in tests. For unit testing error paths, Result types make it trivially easy to test every error case because there are no implicit throws to forget to test — every error path is an explicit err() return that your tests can validate by pattern matching on the result type. This leads to higher error path coverage naturally, since the type system reminds you that both Ok and Err branches exist.

Compare TypeScript utility and error handling packages on PkgPulse →

See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.