Skip to main content

neverthrow vs Effect vs oxide.ts: Result Types in TypeScript (2026)

·PkgPulse Team

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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.