Skip to main content

Effect-TS vs fp-ts: Functional Programming in TypeScript 2026

·PkgPulse Team

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.

Comments

Stay Updated

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