fp-ts vs Effect: Why Most TypeScript Devs Are Switching in 2026

If you’ve been writing TypeScript for more than two years, you’ve probably bumped into fp-ts. Maybe you loved it, maybe you hated its Haskell-imported arcana, maybe you stared at a ReaderTaskEither type signature for ten minutes before closing the tab. Either way, the ecosystem has moved. Effect is now the name everyone’s typing into their package.json, and fp-ts has quietly shifted into maintenance mode.

This article isn’t a cheering section for either camp. It’s a practical breakdown of what each library actually gives you, where each one hurts, and which one you should reach for on a real project in 2026.

Official repos: fp-ts | Effect


The Problem Both Libraries Are Solving

TypeScript’s type system is expressive enough to encode a lot of domain logic — but the standard library gives you almost nothing for the hard parts: nullable values, operations that can fail, async side effects that might throw, and concurrency that doesn’t race itself into corruption.

The JavaScript default is: throw exceptions, catch them somewhere far away, hope you remembered await, and debug production at 2 AM. Both fp-ts and Effect reject that model. They push errors into the type signature so the compiler tells you when you forgot to handle a failure case.

The disagreement is about how much machinery to wrap around that core idea.


fp-ts: The Haskell Port

fp-ts was the first serious attempt to bring typed functional programming idioms into TypeScript. Giulio Canti, the author, essentially ported a chunk of Haskell’s ecosystem: Option, Either, Task, IO, Reader, State, and their monad transformer stack-ups like TaskEither and ReaderTaskEither.

The core abstraction is pipeable functions. You build data transformation pipelines using pipe, compose them with flow, and handle effects by constructing values of the right type.

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

// TaskEither<Error, User> — async operation that can fail
const fetchUser = (id: string): TE.TaskEither<Error, User> =>
  TE.tryCatch(
    () => db.users.findById(id),
    (e) => new Error(`DB error: ${e}`)
  )

const getDisplayName = (user: User): string =>
  `${user.firstName} ${user.lastName}`

// Build a pipeline. The error channel flows automatically.
const program = (id: string) =>
  pipe(
    fetchUser(id),
    TE.map(getDisplayName),
    TE.mapLeft((err) => ({ code: 500, message: err.message }))
  )

// Execute it — TaskEither is lazy, nothing runs until you call it
const result = await program('user-123')()
// result is Either<ApiError, string>

This is genuinely elegant once it clicks. The types are precise. A TaskEither<E, A> is provably an async operation that either produces an A or fails with an E. No surprises. The compiler won’t let you forget the error case.

Where fp-ts Gets Painful

The HKT hack. TypeScript doesn’t have real higher-kinded types. fp-ts simulates them with a URI-based registry (URIS, Kind, Kind2, etc.). This works, but it generates type noise that’s hard to read and even harder to debug. When a type error hits in a deeply composed pipeline, the error messages are brutal.

Verbose composition. Every step is an explicit combinator. For simple happy-path logic with one possible error, the overhead feels disproportionate. pipe(x, TE.map(f), TE.chain(g), TE.mapLeft(h)) is mechanical and correct, but it’s also four lines where one would do in a language with do-notation.

No runtime. fp-ts is pure types and pure functions. That’s fine for pure transformations, but the moment you need structured concurrency — running things in parallel with proper cancellation, managing resource lifecycles, rate-limiting concurrent effects — you’re on your own. You reach for Promise.all, lose the typed error channel, and now you’re half out of the abstraction.

Maintenance velocity. The last major release of fp-ts v3 arrived and then development effectively stalled. The ecosystem is stable but not growing. The author’s energy has shifted toward Effect. Reading that signal correctly matters when you’re choosing a dependency for a production system.


Effect: The Full Platform

Effect started as @effect-ts by Michael Arnaldi, inspired heavily by ZIO from the Scala world. It absorbed a lot of the fp-ts community and graduated to the Effect organization. Today it’s not just a library — it’s a platform with its own schema validation (@effect/schema), platform abstractions for HTTP and file system (@effect/platform), SQL clients, cluster support, and more.

The core type is Effect<A, E, R> — an effect that produces a value of type A, can fail with error type E, and requires services of type R from its environment. That third parameter is the key difference from fp-ts’s TaskEither.

import { Effect, Context } from 'effect'

// Define a service interface
class Database extends Context.Tag('Database')<
  Database,
  { findUser: (id: string) => Effect.Effect<User, DbError> }
>() {}

// An effect that requires Database, can fail with DbError, produces string
const getDisplayName = (id: string): Effect.Effect<string, DbError, Database> =>
  Effect.gen(function* () {
    const db = yield* Database
    const user = yield* db.findUser(id)
    return `${user.firstName} ${user.lastName}`
  })

// Wire up dependencies and run
const program = getDisplayName('user-123').pipe(
  Effect.provideService(Database, {
    findUser: (id) => Effect.tryPromise({
      try: () => realDb.findById(id),
      catch: (e) => new DbError(e)
    })
  })
)

const result = await Effect.runPromise(program)

The generator syntax (Effect.gen) is what makes Effect ergonomic for complex logic. It reads like sequential imperative code but every yield* is a typed, composable effect. The compiler knows the full error union and the full dependency set from the type signature alone.


Concrete Comparison: Structured Concurrency

This is where the gap between the two libraries becomes impossible to ignore.

fp-ts — parallel execution:

import * as TE from 'fp-ts/TaskEither'
import { sequenceT } from 'fp-ts/Apply'

const fetchBoth = sequenceT(TE.ApplyPar)(
  fetchUser('123'),
  fetchConfig('app')
)
// Works, but cancellation? Timeout? Rate limiting? You're writing it yourself.

Effect — parallel execution with structured concurrency:

import { Effect } from 'effect'

const fetchBoth = Effect.all(
  [fetchUser('123'), fetchConfig('app')],
  { concurrency: 'unbounded' }
)

// With a timeout:
const withTimeout = fetchBoth.pipe(
  Effect.timeout('5 seconds')
)

// Parallel with concurrency limit:
const batchProcess = Effect.all(userIds.map(fetchUser), {
  concurrency: 10 // max 10 concurrent, auto-queues the rest
})

Effect’s fiber-based runtime handles cancellation automatically. When a parallel operation fails or times out, the sibling fibers are interrupted — no leaked promises, no dangling DB connections. fp-ts has no answer for this.


Error Handling: Typed Unions vs. Single Error Type

Both libraries push errors into types, but Effect goes further with union error types that survive composition.

With fp-ts you typically pick one error type per pipeline and manually merge them at boundaries. Effect accumulates the error union:

// The return type is automatically: Effect<Result, DbError | ValidationError | NetworkError, Database>
const program = Effect.gen(function* () {
  const input = yield* validateInput(raw)        // can fail: ValidationError
  const user = yield* fetchUser(input.userId)    // can fail: DbError
  const response = yield* callExternalAPI(user)  // can fail: NetworkError
  return response
})

No manual mapLeft merging. The compiler tracks every possible failure. When you handle errors, it tells you if you’ve missed a case.


Gotchas

Effect’s learning curve is still steep. The generator syntax is friendlier than fp-ts’s pipe-heavy style, but the Context / Layer dependency injection system is a new concept most TypeScript developers haven’t encountered. Plan for a week of confusion before your team is productive.

Effect’s bundle size matters for frontend. The core effect package is around 200KB minified before tree-shaking. For a Node.js backend this is irrelevant. For a browser bundle where you care about every kilobyte, think twice before pulling in the full platform.

fp-ts’s pipe pattern still has value. If you’re writing pure data transformations with no side effects, fp-ts’s Option, Either, and Array combinators are excellent and add almost no overhead. You don’t have to adopt a full platform to use typed optional values.

Effect is moving fast. The API has stabilized considerably in the 3.x series, but the platform packages (@effect/platform, @effect/cluster) still iterate quickly. Pin versions and read the changelog before upgrading in production.

Don’t mix them heavily. fp-ts TaskEither and Effect’s Effect type are not interoperable by default. There are adapters (effect/interop), but crossing the boundary too often creates cognitive overhead. Pick one as your primary model.


Production-Ready Pattern: Effect with Layers

The right way to structure an Effect application for production is with Layer — Effect’s dependency injection system. It separates what services you need from how they’re implemented.

import { Effect, Layer, Context } from 'effect'

// Service interfaces (what the app needs)
class UserRepo extends Context.Tag('UserRepo')<
  UserRepo,
  {
    findById: (id: string) => Effect.Effect<User, DbError>
    save: (user: User) => Effect.Effect<void, DbError>
  }
>() {}

class EmailService extends Context.Tag('EmailService')<
  EmailService,
  { send: (to: string, body: string) => Effect.Effect<void, EmailError> }
>() {}

// Business logic — pure Effect, no concrete dependencies
const registerUser = (
  data: RegistrationData
): Effect.Effect<User, DbError | EmailError | ValidationError, UserRepo | EmailService> =>
  Effect.gen(function* () {
    const repo = yield* UserRepo
    const email = yield* EmailService

    const validated = yield* validate(data)
    const user = yield* repo.save(toUser(validated))
    yield* email.send(user.email, welcomeTemplate(user))
    return user
  })

// Production implementations (concrete Layer)
const ProdUserRepo = Layer.succeed(UserRepo, {
  findById: (id) => Effect.tryPromise({
    try: () => pool.query('SELECT * FROM users WHERE id=$1', [id]).then(r => r.rows[0]),
    catch: (e) => new DbError({ cause: e })
  }),
  save: (user) => Effect.tryPromise({
    try: () => pool.query('INSERT INTO users ...', [...]),
    catch: (e) => new DbError({ cause: e })
  })
})

// Test implementation — swap without touching business logic
const TestUserRepo = Layer.succeed(UserRepo, {
  findById: (id) => Effect.succeed(mockUser),
  save: (user) => Effect.succeed(undefined)
})

// Wire and run
const main = registerUser(inputData).pipe(
  Effect.provide(ProdUserRepo),
  Effect.provide(ProdEmailService)
)

This pattern makes testing trivial — provide TestUserRepo instead of ProdUserRepo and you never touch a database in unit tests. The type system enforces that you’ve provided everything the effect needs before it can run.


When to Use What

Use fp-ts if:

  • You have an existing codebase already on fp-ts and migration cost isn’t justified.
  • Your use case is primarily pure data transformation — parsing, validation, mapping — with minimal side effects.
  • You want a minimal dependency with zero runtime overhead (fp-ts is just types and utility functions, the runtime is your Promise chain).

Use Effect if:

  • You’re starting a new backend service and want typed errors from day one.
  • You need structured concurrency — anything with real parallelism, resource management, or timeout semantics.
  • Your team is building a larger system and dependency injection at the type level would help testing and modularity.
  • You want one consistent model for async, sync, error handling, resource management, and observability instead of stitching five libraries together.

The Honest Verdict

fp-ts proved the concept: typed functional programming in TypeScript is feasible and valuable. But it was always a port of patterns from a language with better type system support, and the seams show. The HKT simulation is clever but it’s a load-bearing hack. The lack of a runtime means you hit walls the moment you need real concurrency.

Effect is what fp-ts wanted to be when TypeScript grew up enough. The generator-based syntax removes most of the combinator ceremony. The Layer system solves dependency injection cleanly. The fiber runtime handles concurrency in a way that Promise.all simply cannot. The ecosystem around it — schema, HTTP, SQL, cluster — means you’re not gluing five different libraries with incompatible error models.

The trade-off is real: Effect is a significant commitment. It’s a platform, not a utility. Learning it takes time. But in 2026, for a production TypeScript backend that needs to handle failure gracefully and scale with a team, the choice isn’t particularly close.

Start with Effect on new projects. Keep fp-ts where it already runs until you have a reason to migrate.

👁 Views: 202,779 · Unique visitors: 57,387