Skip to main content

Effect-TS vs fp-ts vs Neverthrow: TS Errors 2026

·PkgPulse Team

Effect-TS vs fp-ts vs Neverthrow: TS Errors 2026

TL;DR

Neverthrow is the pragmatic pick for teams adopting typed errors without a paradigm shift. Effect-TS is the most powerful option — a full async runtime, typed errors, dependency injection, and structured concurrency — but demands a steep learning investment. fp-ts is a battle-tested functional programming toolkit that's being superseded by Effect in most new projects. Start with Neverthrow; graduate to Effect when complexity justifies it.

Key Takeaways

  • fp-ts: 3.7M weekly downloads, 11.5K GitHub stars — widely used but new projects increasingly choose Effect
  • Neverthrow: 1.3M weekly downloads, 7.2K GitHub stars — simple Result type, low overhead
  • Effect-TS: ~13.6K GitHub stars — growing rapidly; v4 beta dropped bundle from ~70 KB to ~20 KB
  • Effect v4 is now in beta as of February 2026 with major bundle size improvements
  • Neverthrow is no longer actively maintained — PRs go unreviewed for months
  • fp-ts and Effect share the same core team — Effect is the official successor
  • All three solve the same core problem: making failure explicit in the type system

The Problem: TypeScript's Silent Failure Mode

JavaScript's throw is invisible to the type system. A function typed as Promise<User> can throw network errors, validation errors, or database failures — none of which appear in the return type. TypeScript pretends everything succeeds.

// Standard TypeScript — failure is invisible
async function getUser(id: string): Promise<User> {
  const user = await db.users.findById(id); // could throw
  return user; // or return null, or throw...
}

// The caller has no idea this can fail
const user = await getUser("123"); // what could go wrong? TypeScript says: nothing

The three libraries in this comparison solve this differently: Neverthrow with a minimal Result<T, E> type, fp-ts with a full functional programming toolkit, and Effect-TS with an entire async runtime.

This problem isn't theoretical. Untyped error handling is one of the leading causes of runtime failures in TypeScript applications. A function that throws a network timeout gets called by a function that calls another function — and somewhere five layers up, there's a catch clause that logs "Unknown error" and swallows the context. Typed errors make the failure modes explicit at every level of the call stack, so callers can handle specific error types and the compiler enforces that every error path is addressed.

The tradeoff is verbosity. Typed error handling requires more code than try/catch and forces you to thread error types through your function signatures. Teams new to this pattern often find it feels heavy for simple CRUD operations. The libraries in this comparison exist on a spectrum from "minimal overhead" (Neverthrow) to "maximum expressiveness at maximum complexity" (Effect-TS), and picking the right level of investment is the central question.

For related TypeScript patterns, see Neverthrow vs Effect-TS vs oxide-ts: Result Types and Effect-TS vs fp-ts 2026.


Comparison Table

DimensionEffect-TS v4fp-ts v2Neverthrow 0.x
Weekly Downloads~200K3.7M1.3M
GitHub Stars13.6K11.5K7.2K
Bundle Size~20 KB (v4)~150 KB~3 KB
ParadigmFull runtime + FPFP toolkitMinimal Result type
Learning curveHighHighLow
Typed errorsYesYes (Either)Yes (Result)
Async handlingBuilt-in fiber runtimeTask, TaskEitherResultAsync
Dependency injectionYesNoNo
Active maintenanceYesMaintenance modeMinimal
Standard adoptionGrowing fastEstablishedStable

Neverthrow

Neverthrow provides one thing: a Result<T, E> type with chainable methods. It's the smallest conceptual leap from standard TypeScript — no new paradigms, no unfamiliar operators.

import { ok, err, Result, ResultAsync } from "neverthrow";

type UserError = { type: "NOT_FOUND" } | { type: "DB_ERROR"; message: string };

function findUser(id: string): Result<User, UserError> {
  const user = userCache.get(id);
  if (!user) return err({ type: "NOT_FOUND" });
  return ok(user);
}

// Chaining
const result = findUser("123")
  .map((user) => ({ ...user, displayName: `${user.firstName} ${user.lastName}` }))
  .mapErr((error) => `Failed: ${error.type}`);

// Pattern matching
if (result.isOk()) {
  console.log(result.value);
} else {
  console.error(result.error);
}

ResultAsync handles promises:

import { ResultAsync } from "neverthrow";

function fetchUser(id: string): ResultAsync<User, UserError> {
  return ResultAsync.fromPromise(
    fetch(`/api/users/${id}`).then((r) => r.json()),
    (error): UserError => ({ type: "DB_ERROR", message: String(error) })
  );
}

// Compose async Results
const result = await fetchUser("123")
  .andThen((user) => fetchUserPreferences(user.id))
  .map((prefs) => prefs.theme);

The maintenance concern is real. Neverthrow's maintainer has been less active since late 2024, with open PRs aging. The library is stable — it's a small codebase with a well-defined scope — but teams starting new projects should weigh this against Effect's active development. Neverthrow's API is stable precisely because it doesn't try to do much. A library that doesn't change isn't necessarily abandoned; it might just be done. Whether that's acceptable depends on your risk tolerance for third-party dependencies.

Neverthrow's approach to composition is worth understanding deeply before committing. The andThen, map, mapErr, and orElse methods enable Railway-Oriented Programming — a pattern where you chain transformations and error handlers without nesting. At its best, this produces very readable code where the happy path flows left-to-right and errors are handled at natural branch points. At scale, however, complex chains can become difficult to debug because intermediate states aren't easily inspected. Effect-TS has better observability tools for this.

When Neverthrow is the right choice:

  • Teams adopting typed errors incrementally without a full paradigm shift
  • Simple CRUD apps where Railway-Oriented Programming covers the error patterns
  • Avoiding fp-ts/Effect's learning curve is a hard requirement for the team
  • Existing codebases adding typed error handling without a full refactor
  • Small libraries or utility packages where a minimal dependency is important

fp-ts

fp-ts brings Haskell-style functional programming to TypeScript: Option, Either, TaskEither, IO, Reader, and the full algebraic data type toolkit. It was the gold standard for typed FP in TypeScript for years.

import * as E from "fp-ts/Either";
import * as TE from "fp-ts/TaskEither";
import { pipe } from "fp-ts/function";

type ApiError = { status: number; message: string };

const fetchUser = (id: string): TE.TaskEither<ApiError, User> =>
  TE.tryCatch(
    () => fetch(`/api/users/${id}`).then((r) => r.json()),
    (error): ApiError => ({ status: 500, message: String(error) })
  );

const getUserDisplayName = (id: string): TE.TaskEither<ApiError, string> =>
  pipe(
    fetchUser(id),
    TE.map((user) => `${user.firstName} ${user.lastName}`),
    TE.mapLeft((err) => ({ ...err, message: `Display name fetch failed: ${err.message}` }))
  );

// Run the computation
const result = await getUserDisplayName("123")();
if (E.isRight(result)) {
  console.log(result.right);
} else {
  console.error(result.left);
}

fp-ts's pipe function is central to everything:

import { pipe } from "fp-ts/function";
import * as O from "fp-ts/Option";
import * as A from "fp-ts/Array";

const processUsers = (users: User[]): O.Option<string[]> =>
  pipe(
    users,
    A.filter((u) => u.isActive),
    A.map((u) => u.email),
    (emails) => (emails.length > 0 ? O.some(emails) : O.none)
  );

fp-ts in 2026: The library is in maintenance mode. The core fp-ts team has moved their energy to Effect-TS, which they consider the spiritual successor. fp-ts v2 receives bug fixes but no new features. For teams already invested in fp-ts, it continues to work well — but for new projects, Effect is the recommended path. The migration from fp-ts to Effect is non-trivial but possible, and the Effect team has published guides for teams making the transition.

The pipe function from fp-ts deserves special mention because it's genuinely influential. The pattern of threading a value through a series of transformations — without the intermediate variable assignment of imperative code — produces a particular kind of readable functional code. Many developers who encounter fp-ts for the first time through pipe end up adopting it even if they don't use the rest of the library. Effect has the same pattern, so fp-ts's core idiom carries over.

Bundle size is fp-ts's most significant disadvantage at ~150 KB. This is primarily a problem for frontend applications. For Node.js backends, 150 KB of runtime code is not meaningful. For frontend bundles where every KB affects Time to Interactive, fp-ts is a poor choice. Teams that want functional error handling on the frontend should look at Neverthrow (3 KB) or Valibot with custom Result types instead.

When fp-ts is the right choice:

  • Existing fp-ts codebases that are stable, well-tested, and deeply understood by the team
  • Teams with strong functional programming background who prefer fp-ts's minimal scope
  • Projects using fp-ts's Option and Array utilities extensively where Effect's approach differs
  • When you want to incrementally migrate to Effect — fp-ts and Effect share conceptual DNA

Effect-TS

Effect-TS is not just an error handling library — it's a complete TypeScript runtime for building production applications. Think of it as an answer to: what if TypeScript had structured concurrency, typed errors, dependency injection, resource management, and observability built in?

import { Effect, pipe } from "effect";

// Three type parameters: success, error, requirements (dependencies)
type UserEffect = Effect.Effect<User, UserNotFound | DatabaseError, UserRepository>;

const findUser = (id: string): Effect.Effect<User, UserNotFound | DatabaseError, UserRepository> =>
  Effect.flatMap(UserRepository, (repo) =>
    Effect.tryPromise({
      try: () => repo.findById(id),
      catch: (error): DatabaseError => ({ _tag: "DatabaseError", error }),
    })
  ).pipe(
    Effect.flatMap((user) =>
      user ? Effect.succeed(user) : Effect.fail<UserNotFound>({ _tag: "UserNotFound", id })
    )
  );

// Composing effects
const getUserWithPermissions = (id: string) =>
  pipe(
    findUser(id),
    Effect.flatMap((user) => fetchPermissions(user.id)),
    Effect.map(([user, permissions]) => ({ ...user, permissions }))
  );

Effect v4 (beta, February 2026) brings the bundle down from ~70 KB to ~20 KB for a minimal program including Stream and Schema — a major improvement for adoption concerns. The v4 team has focused heavily on making Effect more accessible: better error messages, cleaner stack traces, improved documentation, and the bundle size improvement that makes frontend usage more viable. Effect v4 is the version where the library crosses from "impressive but too heavy for most projects" to "worth seriously evaluating for complex backend applications."

The learning curve for Effect is real and shouldn't be underestimated. The three-parameter generic Effect<Success, Error, Requirements> is unfamiliar to most TypeScript developers. The pipe-heavy style requires comfort with function composition. The concept of a "fiber" (Effect's concurrency primitive) introduces new mental models for async code. Teams that have successfully adopted Effect report 2-4 weeks before developers feel productive, and 2-3 months before the patterns feel natural. Companies like Harbor have written publicly about choosing not to adopt Effect because the learning investment exceeded the benefit for their use case — that's a legitimate outcome, not a failure of the library.

// Effect's dependency injection
import { Context, Layer } from "effect";

interface UserRepository {
  findById: (id: string) => Promise<User | null>;
}
const UserRepository = Context.GenericTag<UserRepository>("UserRepository");

const LiveUserRepository = Layer.succeed(UserRepository, {
  findById: (id) => db.users.findById(id),
});

// Test implementation
const TestUserRepository = Layer.succeed(UserRepository, {
  findById: (id) => Promise.resolve({ id, name: "Test User", email: "test@example.com" }),
});

// Provide dependencies at the boundary
Effect.runPromise(
  pipe(getUserWithPermissions("123"), Effect.provide(LiveUserRepository))
);

When Effect-TS is the right choice:

  • Complex backend services where structured concurrency and resource management matter
  • Teams willing to invest in the learning curve (expect 2-4 weeks to productive velocity)
  • Applications needing built-in retry, timeout, and circuit breaker patterns
  • Projects where testability through dependency injection is a priority
  • Long-lived services where the operational benefits (observability, structured errors, resource cleanup) justify the upfront complexity cost

When to Choose Each

Choose Neverthrow for incremental adoption — it's the smallest step toward typed errors and works with any existing TypeScript codebase. The 3 KB bundle fits anywhere, the API is learnable in an afternoon, and the Result type integrates cleanly with existing code. Accept the reduced maintenance activity for stable, well-scoped codebases.

Choose fp-ts only if you're maintaining an existing fp-ts codebase with a team that knows it well. For new projects in 2026, Effect-TS is the better investment at the same learning curve. The fp-ts team's own guidance is to evaluate Effect for new projects.

Choose Effect-TS when building complex, long-lived backend services and your team can absorb the learning curve. Effect v4's improved bundle size removes the last major adoption barrier. The expressiveness payoff is real for sufficiently complex systems — teams that have adopted it often describe it as making previously-difficult problems (retry with exponential backoff, request cancellation, complex concurrent workflows) feel straightforward.

The honest answer for most teams: Start with Neverthrow. Add typed errors to your 10 most important functions. Learn what it feels like to have failure modes visible in your type signatures. If you find yourself wanting more — dependency injection, structured concurrency, schema validation as part of the same ecosystem — then evaluate Effect. The transition from Neverthrow-style thinking to Effect-style thinking is a manageable conceptual leap.


A Note on the Result Pattern vs Exceptions

One question teams often ask when evaluating these libraries: should we go all-in on typed errors for every function, or only for functions that can "meaningfully fail"? The answer that works best in practice is selective adoption — use typed errors at service boundaries and external API calls, where failure modes are varied and callers need to handle them explicitly. Keep exceptions for truly unexpected errors (programming bugs, out-of-memory, etc.) that represent broken invariants rather than expected failure cases. This hybrid approach avoids the "Result for everything" trap where simple getters and pure functions become verbose for no benefit.

All three libraries support this hybrid approach. Neverthrow's fromThrowable utility wraps exception-throwing functions into Result-returning functions. fp-ts's tryCatch does the same. Effect's Effect.tryPromise and Effect.try wrap throwing code at the boundary. The pattern is: keep your own code typed-error-aware, wrap third-party libraries at the edges, and let exceptions propagate from true bugs.

Methodology

Download statistics from npm trends (March 2026). GitHub stars from repository pages. Bundle sizes from Bundlephobia and official Effect v4 release notes (effect.website blog). Maintenance status assessed from GitHub commit activity and open PR response times over the 6 months prior to publication.

Comments

Get the 2026 npm Stack Cheatsheet

Our top package picks for every category — ORMs, auth, testing, bundlers, and more. Plus weekly npm trend reports.