Skip to main content

Effect-TS vs fp-ts (2026)

·PkgPulse Team
0

The author of fp-ts (Giulio Canti) joined the Effect organization — and Effect is now officially what fp-ts v3 would have been. That's not a casual endorsement. It's an acknowledgment that fp-ts solved the "what to use" question for typed functional programming in TypeScript, and Effect solved the "how to build real systems" question that fp-ts left partially answered.

TL;DR

fp-ts is the established foundation: excellent for typed functional composition, Option, Either, Task, and type-safe data transformations. Effect is the complete system: everything fp-ts does plus fiber-based concurrency, dependency injection, structured error handling, observability, and a runtime designed for production applications. fp-ts is being merged into the Effect ecosystem — Effect is the future.

Key Takeaways

  • fp-ts: 3.1M weekly npm downloads, 11K GitHub stars — established, widely used
  • Effect (effect package): Growing rapidly, fp-ts creator joined the team
  • Giulio Canti (fp-ts creator) officially joined Effect team — Effect is the successor
  • Effect has fiber-based concurrency; fp-ts does not
  • Effect includes: error handling, dependency injection, streams, testing utilities, tracing
  • fp-ts is more minimal and composable; Effect is more opinionated and batteries-included
  • Learning curve: fp-ts is steep; Effect is very steep initially, then productive

Why Functional Programming in TypeScript?

Functional programming in TypeScript solves real production problems:

  • Explicit error handling: Return Either<Error, Value> instead of throwing
  • Composability: Chain operations without mutation side effects
  • Testability: Pure functions are trivially testable
  • Predictability: No hidden state, no surprise mutations

The question is which abstraction layer to use.

fp-ts: The Foundation

Package: fp-ts Weekly downloads: 3.1M GitHub stars: 11K Creator: Giulio Canti

fp-ts brings Haskell's category theory-inspired abstractions to TypeScript: Option, Either, Task, Reader, State, IO, and monadic composition.

Core Data Types

import { Option, some, none, map, getOrElse } from 'fp-ts/Option';
import { Either, right, left, chain, mapLeft } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

// Option: represents a value that may or may not exist
const findUser = (id: string): Option<User> => {
  const user = db.get(id);
  return user ? some(user) : none;
};

const userName = pipe(
  findUser('123'),
  map(user => user.name),
  getOrElse(() => 'Unknown User')
);

// Either: represents success OR failure
const parseAge = (input: string): Either<string, number> => {
  const age = parseInt(input);
  if (isNaN(age)) return left(`"${input}" is not a valid age`);
  if (age < 0) return left('Age cannot be negative');
  return right(age);
};

const validateUser = (data: unknown): Either<string, User> =>
  pipe(
    parseAge(String(data.age)),
    map(age => ({ ...data, age }))
  );

Task: Async Operations

import { Task } from 'fp-ts/Task';
import { TaskEither } from 'fp-ts/TaskEither';
import * as TE from 'fp-ts/TaskEither';

// TaskEither: async operation that may fail
const fetchUser = (id: string): TaskEither<Error, User> =>
  TE.tryCatch(
    () => fetch(`/api/users/${id}`).then(r => r.json()),
    (error) => new Error(String(error))
  );

// Chain operations
const getUserName = (id: string): TaskEither<Error, string> =>
  pipe(
    fetchUser(id),
    TE.map(user => user.name),
    TE.mapLeft(error => new Error(`Failed to get user: ${error.message}`))
  );

// Execute the effect
const main = async () => {
  const result = await getUserName('123')();
  // result is Either<Error, string>
  if (result._tag === 'Right') {
    console.log(result.right); // the name
  } else {
    console.error(result.left); // the error
  }
};

fp-ts Limitations

fp-ts has real production limitations:

  • No built-in concurrency model (parallel async tasks require manual orchestration)
  • No dependency injection system (Reader monad works but is verbose)
  • No streaming primitives
  • No built-in retry, timeout, or circuit breaker patterns
  • Steep learning curve + verbose pipe chains

Effect: The Production System

Package: effect GitHub stars: 9K (growing fast) Creator: Effect team (including Giulio Canti, formerly fp-ts)

Effect is a complete functional programming platform for TypeScript. It includes everything fp-ts has plus a production-grade runtime with fiber-based concurrency, structured error handling, dependency injection, and observability.

The Effect Type

The core type is Effect<A, E, R>:

  • A: The success value type
  • E: The expected error type
  • R: The required services/dependencies
import { Effect, pipe } from 'effect';

// Effect that returns string, may fail with Error, requires DatabaseService
type GetUserName = Effect.Effect<string, Error, DatabaseService>;

Basic Usage

import { Effect, Console } from 'effect';

// Create effects
const hello = Effect.succeed('Hello, World!');
const fail = Effect.fail(new Error('Something went wrong'));

// Transform effects
const greeting = pipe(
  Effect.succeed({ name: 'Alice', age: 30 }),
  Effect.map(user => `Hello, ${user.name}!`),
  Effect.flatMap(greeting => Console.log(greeting))
);

// Run effects
import { runPromise } from 'effect/Effect';
await runPromise(greeting); // Logs "Hello, Alice!"

Structured Error Handling

Effect's error handling is more expressive than fp-ts:

import { Effect, Data } from 'effect';

// Tagged error classes (discriminated unions)
class UserNotFoundError extends Data.TaggedError('UserNotFoundError')<{ userId: string }> {}
class DatabaseError extends Data.TaggedError('DatabaseError')<{ cause: unknown }> {}

const findUser = (userId: string): Effect.Effect<User, UserNotFoundError | DatabaseError> =>
  Effect.tryPromise({
    try: () => db.findUser(userId),
    catch: (error) => new DatabaseError({ cause: error }),
  }).pipe(
    Effect.flatMap(user =>
      user ? Effect.succeed(user) : Effect.fail(new UserNotFoundError({ userId }))
    )
  );

// Handle errors by type
const result = findUser('123').pipe(
  Effect.catchTag('UserNotFoundError', (err) =>
    Effect.succeed({ name: 'Anonymous', id: err.userId })
  ),
  Effect.catchTag('DatabaseError', (err) =>
    Effect.fail(new Error(`DB error: ${err.cause}`))
  )
);

Dependency Injection

Effect's most powerful feature for production apps:

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

// Define services
class DatabaseService extends Context.Tag('DatabaseService')<
  DatabaseService,
  { findUser: (id: string) => Promise<User | null> }
>() {}

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

// Use services in effects (R type captures dependencies)
const sendWelcomeEmail = (userId: string): Effect.Effect<
  void,
  Error,
  DatabaseService | EmailService
> =>
  Effect.gen(function* () {
    const db = yield* DatabaseService;
    const email = yield* EmailService;

    const user = yield* Effect.promise(() => db.findUser(userId));
    if (!user) yield* Effect.fail(new Error(`User ${userId} not found`));

    yield* Effect.promise(() =>
      email.send(user!.email, 'Welcome!', `Hello ${user!.name}!`)
    );
  });

// Provide implementations (like a DI container)
const DatabaseServiceLive = Layer.succeed(DatabaseService, {
  findUser: (id) => db.users.findById(id),
});

const EmailServiceLive = Layer.succeed(EmailService, {
  send: (to, subject, body) => sendgrid.send({ to, subject, text: body }),
});

// Run with dependencies
const program = sendWelcomeEmail('123').pipe(
  Effect.provide(Layer.merge(DatabaseServiceLive, EmailServiceLive))
);

await Effect.runPromise(program);

Fiber-Based Concurrency

Effect's killer feature for production systems:

import { Effect, Fiber } from 'effect';

// Parallel execution
const parallel = Effect.all([
  fetchUserProfile(userId),
  fetchUserOrders(userId),
  fetchUserPreferences(userId),
], { concurrency: 'unbounded' }); // All three run in parallel

// Race (first to complete wins)
const raceResult = Effect.race(
  fetchFromPrimaryDB(id),
  fetchFromReplicaDB(id)
);

// Timeout
const withTimeout = fetchData().pipe(
  Effect.timeout('5 seconds')
);

// Retry with backoff
const withRetry = fetchData().pipe(
  Effect.retry(Schedule.exponential(1000).pipe(Schedule.take(3)))
);

// Interrupt on cancellation (fibers automatically clean up)
const fiber = yield* Effect.fork(longRunningProcess());
// ... later
yield* Fiber.interrupt(fiber); // Clean shutdown

Effect.gen: Readable Async Code

Effect.gen uses generators for readable, synchronous-looking async code:

const processOrder = (orderId: string) =>
  Effect.gen(function* () {
    // Looks like synchronous code, runs as async Effect
    const order = yield* getOrder(orderId);
    const user = yield* getUser(order.userId);
    const inventory = yield* checkInventory(order.items);

    if (!inventory.available) {
      yield* Effect.fail(new InsufficientInventoryError({ orderId }));
    }

    yield* chargePayment(user.paymentMethod, order.total);
    yield* updateOrderStatus(orderId, 'confirmed');
    yield* sendConfirmationEmail(user.email, order);

    return { success: true, orderId };
  });

The fp-ts to Effect Migration Path

Since fp-ts is merging with Effect, there's a clear migration guide:

// fp-ts: Option
import { Option, some, none, map } from 'fp-ts/Option';
const opt: Option<number> = some(42);
const doubled = map((n: number) => n * 2)(opt);

// Effect: Option
import { Option } from 'effect';
const opt = Option.some(42);
const doubled = Option.map(opt, n => n * 2);

// fp-ts: Either
import { Either, right, left, chain } from 'fp-ts/Either';
const result: Either<Error, User> = right(user);

// Effect: Either
import { Either } from 'effect';
const result = Either.right(user);

When to Use Each

Use fp-ts if:

  • Your codebase already has fp-ts and migration isn't feasible
  • You want the minimal, composable building blocks
  • Your team is comfortable with the Haskell-inspired API
  • You need only data transformation (Option, Either, Task) without a runtime

Use Effect if:

  • Starting a new TypeScript project with functional patterns
  • Production concerns: retries, timeouts, circuit breakers are needed
  • Dependency injection without a separate DI framework
  • Concurrency: parallel, racing, fork/join patterns
  • You want built-in telemetry and tracing
  • Testing with dependency injection (easy to swap implementations in tests)

The Bottom Line

fp-ts was the right answer for functional TypeScript for years. Effect is the right answer for 2026 and beyond — especially now that the fp-ts creator is building it. If you're starting a new project that benefits from functional programming patterns (explicit errors, immutability, composition), start with Effect.

Compare these packages on PkgPulse.

Compare Effect and fp-ts package health on PkgPulse.

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.