Skip to content
Unverified — AI-generated content. Help verify this page

FP in TypeScript

Why FP in TypeScript?

TypeScript's type system is expressive enough to support FP patterns that were previously only practical in Haskell, Scala, or ML-family languages. With discriminated unions, generic type inference, and structural typing, you can build type-safe FP abstractions without sacrificing developer experience.

The ecosystem has matured significantly:

LibraryPhilosophySizeLearning Curve
fp-tsHaskell-inspired, category theory~40 KBSteep
EffectZIO-inspired, full runtime~200 KBModerate
purify-tsLightweight, practical~10 KBGentle
neverthrowResult type only~3 KBMinimal
ts-patternPattern matching~5 KBMinimal
Vanilla TSNo library, just patterns0 KBVaries

The Pipe Operator

Since TypeScript does not have a native pipe operator (the TC39 proposal has stalled), every FP library provides its own. The pipe function is the backbone of FP TypeScript code:

typescript
// fp-ts pipe
import { pipe } from 'fp-ts/function';

const result = pipe(
  ' Hello, World! ',
  s => s.trim(),
  s => s.toLowerCase(),
  s => s.replace(/\s+/g, '-'),
  s => `slug:${s}`,
);
// "slug:hello,-world!"

// The flow function creates a reusable pipeline
import { flow } from 'fp-ts/function';

const slugify = flow(
  (s: string) => s.trim(),
  s => s.toLowerCase(),
  s => s.replace(/[^\w\s-]/g, ''),
  s => s.replace(/\s+/g, '-'),
);

slugify(' Hello World! '); // "hello-world"
slugify('  FP in TypeScript  '); // "fp-in-typescript"

fp-ts

fp-ts is the most comprehensive FP library for TypeScript, modeled after Haskell's type class hierarchy. It provides Option, Either, Task, TaskEither, Reader, and many more.

Option (Maybe)

typescript
import { pipe } from 'fp-ts/function';
import * as O from 'fp-ts/Option';

// Creating Options
const some = O.some(42);          // Option<number> containing 42
const none = O.none;               // Option<never> — empty
const fromNull = O.fromNullable(user?.name); // null → None, value → Some

// Chaining with pipe
interface Config {
  database?: {
    host?: string;
    port?: number;
  };
}

function getDatabasePort(config: Config): number {
  return pipe(
    O.fromNullable(config.database),
    O.flatMap(db => O.fromNullable(db.port)),
    O.getOrElse(() => 5432),
  );
}

Either (Result)

typescript
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

type AppError =
  | { _tag: 'ValidationError'; message: string }
  | { _tag: 'NotFoundError'; resource: string; id: string }
  | { _tag: 'UnauthorizedError' };

function parsePositiveInt(input: string): E.Either<AppError, number> {
  const n = parseInt(input, 10);
  if (isNaN(n)) {
    return E.left({ _tag: 'ValidationError', message: `"${input}" is not a number` });
  }
  if (n <= 0) {
    return E.left({ _tag: 'ValidationError', message: `${n} is not positive` });
  }
  return E.right(n);
}

function findUser(id: number): E.Either<AppError, User> {
  const user = users.get(id);
  return user
    ? E.right(user)
    : E.left({ _tag: 'NotFoundError', resource: 'User', id: String(id) });
}

// Compose operations — short-circuits on first Left
const getUserProfile = (rawId: string): E.Either<AppError, UserProfile> =>
  pipe(
    parsePositiveInt(rawId),
    E.flatMap(findUser),
    E.map(user => ({
      id: user.id,
      name: user.name,
      memberSince: user.createdAt.toISOString(),
    })),
  );

// Pattern match the result
pipe(
  getUserProfile(req.params.id),
  E.match(
    error => {
      switch (error._tag) {
        case 'ValidationError': return res.status(400).json(error);
        case 'NotFoundError': return res.status(404).json(error);
        case 'UnauthorizedError': return res.status(401).json(error);
      }
    },
    profile => res.status(200).json(profile),
  ),
);

TaskEither (Async Result)

TaskEither is the most commonly used fp-ts type in production code — it combines Promise (async) with Either (error handling):

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

// Wrap async operations that can fail
function fetchUser(id: string): TE.TaskEither<AppError, User> {
  return TE.tryCatch(
    () => db.query('SELECT * FROM users WHERE id = $1', [id]),
    (reason) => ({ _tag: 'DatabaseError' as const, reason: String(reason) }),
  );
}

function chargePayment(userId: string, amount: number): TE.TaskEither<AppError, Payment> {
  return TE.tryCatch(
    () => stripe.charges.create({ amount, customer: userId }),
    (reason) => ({ _tag: 'PaymentError' as const, reason: String(reason) }),
  );
}

// Compose async operations — railway-oriented
const processOrder = (orderId: string): TE.TaskEither<AppError, OrderConfirmation> =>
  pipe(
    fetchOrder(orderId),
    TE.flatMap(order =>
      pipe(
        fetchUser(order.userId),
        TE.flatMap(user => chargePayment(user.stripeId, order.total)),
        TE.map(payment => ({
          orderId: order.id,
          paymentId: payment.id,
          status: 'confirmed' as const,
        })),
      ),
    ),
  );

// Execute
const result = await processOrder('ord_123')();
// result: Either<AppError, OrderConfirmation>

Array Utilities

fp-ts provides functional array operations that return Option instead of throwing or returning undefined:

typescript
import * as A from 'fp-ts/Array';
import * as NEA from 'fp-ts/NonEmptyArray';

// Safe head — returns Option, not T | undefined
pipe(
  [1, 2, 3],
  A.head,
); // O.some(1)

pipe(
  [] as number[],
  A.head,
); // O.none

// NonEmptyArray guarantees at least one element
function average(numbers: NEA.NonEmptyArray<number>): number {
  return pipe(
    numbers,
    NEA.reduce(0, (acc, n) => acc + n),
  ) / numbers.length;
}

Effect (formerly ZIO for TypeScript)

Effect is a newer, more ambitious library that provides a complete effect system. Where fp-ts ports Haskell idioms, Effect builds a full runtime with dependency injection, concurrency, retry, scheduling, and observability built in.

Core Types

typescript
import { Effect, pipe } from 'effect';

// Effect<Success, Error, Requirements>
// Success = what it produces on success
// Error = what error types it can fail with
// Requirements = what services it needs (dependency injection)

const divide = (a: number, b: number): Effect.Effect<number, Error> =>
  b === 0
    ? Effect.fail(new Error('Division by zero'))
    : Effect.succeed(a / b);

// Run it
const result = Effect.runSync(divide(10, 2)); // 5

Service Pattern (Dependency Injection)

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

// Define a service interface
class UserRepo extends Context.Tag('UserRepo')<
  UserRepo,
  {
    findById: (id: string) => Effect.Effect<User, NotFoundError>;
    save: (user: User) => Effect.Effect<void, DatabaseError>;
  }
>() {}

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

// Use services in business logic
const registerUser = (input: RegisterInput) =>
  Effect.gen(function* () {
    const userRepo = yield* UserRepo;
    const emailService = yield* EmailService;

    const user = User.create(input);
    yield* userRepo.save(user);
    yield* emailService.send(user.email, 'Welcome!', 'Thanks for joining.');

    return user;
  });

// Provide implementations via Layers
const PostgresUserRepoLive = Layer.succeed(UserRepo, {
  findById: (id) => Effect.tryPromise(() => db.query('SELECT * FROM users WHERE id = $1', [id])),
  save: (user) => Effect.tryPromise(() => db.query('INSERT INTO users ...', [user])),
});

const SesEmailServiceLive = Layer.succeed(EmailService, {
  send: (to, subject, body) => Effect.tryPromise(() => ses.sendEmail({ to, subject, body })),
});

// Wire everything together
const MainLive = Layer.merge(PostgresUserRepoLive, SesEmailServiceLive);

// Run with all dependencies provided
Effect.runPromise(
  registerUser({ email: 'alice@example.com', name: 'Alice' }).pipe(
    Effect.provide(MainLive),
  ),
);

Effect Generators

Effect's generator syntax (Effect.gen) reads like imperative code while maintaining full type safety:

typescript
const checkout = (orderId: string) =>
  Effect.gen(function* () {
    const orderRepo = yield* OrderRepo;
    const payments = yield* PaymentService;
    const events = yield* EventBus;

    const order = yield* orderRepo.findById(orderId);

    if (order.total <= 0) {
      return yield* Effect.fail(new InvalidOrderError('Total must be positive'));
    }

    const charge = yield* payments.charge(order.customerId, order.total);

    order.markPaid(charge.id);
    yield* orderRepo.save(order);
    yield* events.publish(new OrderPaidEvent(order.id, charge.id));

    return { orderId: order.id, chargeId: charge.id };
  });

purify-ts

purify-ts is a lightweight alternative for teams that want FP types without the category theory overhead:

typescript
import { Maybe, Just, Nothing } from 'purify-ts/Maybe';
import { EitherAsync } from 'purify-ts/EitherAsync';

// Maybe
const name = Maybe.fromNullable(user?.profile?.name)
  .map(n => n.trim())
  .filter(n => n.length > 0)
  .orDefault('Anonymous');

// EitherAsync for async error handling
const createOrder = EitherAsync(async ({ liftEither, throwE }) => {
  const user = await fetchUser(userId).run();
  if (user.isLeft()) throwE(user.extract());

  const validOrder = liftEither(validateOrder(input));
  const saved = await saveOrder(validOrder).run();

  return saved;
});

Branded Types

Branded types (also called opaque types or newtypes) prevent mixing up values that share the same underlying type. They are a lightweight way to bring type safety to domain concepts.

typescript
// Declare branded types
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
type Branded<T, B> = T & Brand<B>;

// Domain types that are all strings, but cannot be mixed up
type UserId = Branded<string, 'UserId'>;
type OrderId = Branded<string, 'OrderId'>;
type Email = Branded<string, 'Email'>;

// Smart constructors that validate
function UserId(value: string): UserId {
  if (!value.startsWith('usr_')) throw new Error('Invalid UserId format');
  return value as UserId;
}

function Email(value: string): Email {
  if (!value.includes('@')) throw new Error('Invalid email format');
  return value.toLowerCase() as Email;
}

// Type system prevents mixing IDs
function findUser(id: UserId): Promise<User> { /* ... */ }
function findOrder(id: OrderId): Promise<Order> { /* ... */ }

const userId = UserId('usr_123');
const orderId = OrderId('ord_456');

findUser(userId);    // OK
findUser(orderId);   // Compile error! OrderId is not UserId

With Effect Schema

typescript
import { Schema } from 'effect';

const UserId = Schema.String.pipe(
  Schema.brand('UserId'),
  Schema.filter(s => s.startsWith('usr_'), {
    message: () => 'Must start with usr_',
  }),
);
type UserId = Schema.Schema.Type<typeof UserId>;

const Email = Schema.String.pipe(
  Schema.brand('Email'),
  Schema.filter(s => /^[\w.+-]+@[\w-]+\.[\w.]+$/.test(s)),
  Schema.transform(Schema.String, { decode: s => s.toLowerCase(), encode: s => s }),
);

Practical Patterns

Pattern 1: Validation Accumulation

Collect all validation errors instead of stopping at the first:

typescript
import * as E from 'fp-ts/Either';
import * as A from 'fp-ts/Apply';
import { pipe } from 'fp-ts/function';
import * as NEA from 'fp-ts/NonEmptyArray';

type ValidationErrors = NEA.NonEmptyArray<string>;
type Validated<T> = E.Either<ValidationErrors, T>;

const validateName = (name: string): Validated<string> =>
  name.length >= 2
    ? E.right(name)
    : E.left(NEA.of('Name must be at least 2 characters'));

const validateEmail = (email: string): Validated<string> =>
  email.includes('@')
    ? E.right(email.toLowerCase())
    : E.left(NEA.of('Invalid email format'));

const validateAge = (age: number): Validated<number> =>
  age >= 18 && age <= 120
    ? E.right(age)
    : E.left(NEA.of('Age must be between 18 and 120'));

// Accumulate ALL errors using Apply
const validateUser = (input: { name: string; email: string; age: number }) =>
  pipe(
    E.Do,
    E.apS('name', validateName(input.name)),
    E.apS('email', validateEmail(input.email)),
    E.apS('age', validateAge(input.age)),
  );

// Returns Left(["Name must be at least 2 characters", "Invalid email format"])
// instead of stopping at first error

Pattern 2: Exhaustive Pattern Matching

typescript
import { match, P } from 'ts-pattern';

type ApiResponse =
  | { status: 'success'; data: User }
  | { status: 'not_found'; id: string }
  | { status: 'unauthorized'; reason: string }
  | { status: 'rate_limited'; retryAfter: number };

const handleResponse = (response: ApiResponse) =>
  match(response)
    .with({ status: 'success' }, ({ data }) => renderUser(data))
    .with({ status: 'not_found' }, ({ id }) => render404(id))
    .with({ status: 'unauthorized' }, ({ reason }) => redirectToLogin(reason))
    .with({ status: 'rate_limited' }, ({ retryAfter }) => showRetryMessage(retryAfter))
    .exhaustive(); // Compile error if a case is missed!

Pattern 3: Functional Core, Imperative Shell

typescript
// === FUNCTIONAL CORE (pure, no I/O) ===

function calculateDiscount(order: Order, promos: Promotion[]): DiscountResult {
  const applicable = promos
    .filter(p => p.isActive && p.appliesTo(order))
    .sort((a, b) => b.value - a.value);

  if (applicable.length === 0) return { discount: 0, applied: [] };

  const best = applicable[0];
  return {
    discount: best.calculate(order.subtotal),
    applied: [best.code],
  };
}

// === IMPERATIVE SHELL (handles I/O, calls the pure core) ===

async function handleCheckout(req: Request, res: Response) {
  // I/O: read from database
  const order = await orderRepo.findById(req.params.id);
  const promos = await promoRepo.findActive();

  // Pure: calculate discount (no I/O, no side effects)
  const discountResult = calculateDiscount(order, promos);

  // I/O: apply result and persist
  order.applyDiscount(discountResult);
  await orderRepo.save(order);
  await eventBus.publish(new OrderDiscountApplied(order.id, discountResult));

  res.json(order.toResponse());
}

Decision Matrix

NeedSolutionLibrary
Just Result<T, E>neverthrow or hand-rolledneverthrow
Option + Either + pipefp-ts corefp-ts
Full effect system with DIEffecteffect
Lightweight FP typespurify-tspurify-ts
Pattern matchingts-patternts-pattern
Branded/opaque typesHand-rolled or Effect Schema-

Further Reading

"What I cannot create, I do not understand." — Richard Feynman