Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save chengyixu/07b02a00621fbd28744d75dd11c6df78 to your computer and use it in GitHub Desktop.

Select an option

Save chengyixu/07b02a00621fbd28744d75dd11c6df78 to your computer and use it in GitHub Desktop.
Beyond try/catch: Railway-Oriented Programming in TypeScript for Reliable Backends — Result types, neverthrow, and Effect-TS patterns for production-grade error handling

Beyond try/catch: Railway-Oriented Programming in TypeScript for Reliable Backends

Introduction

Every production outage I've debugged in the past three years shares one root cause: an unhandled exception somewhere in the call stack that propagated silently, corrupted state, or crashed the process entirely. A database connection dropped mid-transaction. An external API returned an unexpected shape. A file that should have existed didn't. In each case, the code relied on try/catch blocks scattered across the codebase like bandaids -- and each time, something slipped through.

The problem with try/catch is not that it fails to catch errors. It's that try/catch treats errors as a separate control flow mechanism, invisible to the type system and invisible to anyone reading the function signature. You look at function processOrder(id: string): Order and have no idea it can fail. The compiler won't warn you. Your IDE won't autocomplete the error case. The only way to know is to read the implementation or wait for it to blow up in production.

Railway-Oriented Programming (ROP) solves this by making errors a first-class citizen of your type system. Instead of throwing exceptions, functions return a Result<T, E> type that encodes both success and failure paths. The compiler forces every caller to handle both. The type signature documents every possible failure. And composition operators let you chain operations without nesting try/catch blocks ten levels deep. This article walks through the pattern from basic Result types to production-grade error handling using neverthrow and Effect-TS, with real runnable examples drawn from payment processing, API middleware, and database operations.

The Problem: Exceptions Are Invisible

Before introducing the solution, let's make the problem concrete. Here's a typical Express route handler for creating an order:

// routes/orders.ts
app.post('/orders', async (req, res) => {
  try {
    const { userId, items } = req.body;
    const user = await db.users.findById(userId);
    if (!user) return res.status(404).json({ error: 'User not found' });

    const inventory = await db.inventory.checkAvailability(items);
    if (!inventory.available) {
      return res.status(400).json({ error: 'Items out of stock' });
    }

    const total = calculateTotal(items);
    const payment = await paymentService.charge(user.stripeId, total);
    if (payment.status !== 'succeeded') {
      return res.status(402).json({ error: 'Payment failed' });
    }

    const order = await db.orders.create({ userId, items, total, paymentId: payment.id });
    return res.status(201).json(order);
  } catch (err) {
    console.error('Order creation failed:', err);
    return res.status(500).json({ error: 'Internal server error' });
  }
});

This looks reasonable at first glance. But here is what can go wrong:

  1. db.users.findById throws -- caught by the outer try/catch, returns 500. The user sees "Internal server error" when the real issue might be a network partition or a malformed query.

  2. paymentService.charge throws -- returns 500. But the payment may have already been processed. The Stripe charge succeeded; the error happened on the response parsing. Now you have a double-charge situation and no order record.

  3. calculateTotal throws -- a TypeError because items was null instead of an array. The validation at line 4 never ran because req.body destructuring doesn't validate types.

  4. Any function throws due to a transient error -- the catch block loses all context about where the error originated and what we were trying to do. The only recovery mechanism is logging and returning 500.

The core issue: exceptions are invisible to the type system. There is no way to know from the function signature that calculateTotal(items) can fail, what kind of error it produces, or whether the caller is expected to handle it. The compiler gives you zero help.

Core Pattern: The Result Type

The Result type (also called Either in functional programming) is a union of two variants:

type Result<T, E> = { success: true; value: T } | { success: false; error: E };

A function that returns Result<User, NotFoundError | DatabaseError> communicates in its type signature that:

  • It can succeed with a User object
  • It can fail with either a NotFoundError or a DatabaseError
  • The compiler WILL force callers to handle both cases

Here is the simplest possible implementation:

// result.ts
type Success<T> = { success: true; value: T };
type Failure<E> = { success: false; error: E };

export type Result<T, E> = Success<T> | Failure<E>;

export function ok<T>(value: T): Success<T> {
  return { success: true, value };
}

export function err<E>(error: E): Failure<E> {
  return { success: false, error };
}

And here is the order creation route rewritten with Result:

// order-service.ts
import { Result, ok, err } from './result';

type OrderError =
  | { type: 'NOT_FOUND'; message: string }
  | { type: 'OUT_OF_STOCK'; itemIds: string[] }
  | { type: 'PAYMENT_FAILED'; reason: string }
  | { type: 'DATABASE_ERROR'; cause: unknown };

async function createOrder(
  userId: string,
  items: { id: string; quantity: number }[]
): Promise<Result<Order, OrderError>> {
  const user = await db.users.findById(userId);
  if (!user) {
    return err({ type: 'NOT_FOUND', message: `User ${userId} not found` });
  }

  const availability = await db.inventory.checkAvailability(items);
  if (!availability.available) {
    return err({ type: 'OUT_OF_STOCK', itemIds: availability.unavailableIds });
  }

  let payment;
  try {
    payment = await paymentService.charge(user.stripeId, calculateTotal(items));
  } catch (cause) {
    return err({ type: 'PAYMENT_FAILED', reason: String(cause) });
  }

  if (payment.status !== 'succeeded') {
    return err({ type: 'PAYMENT_FAILED', reason: payment.failureReason ?? 'Unknown' });
  }

  try {
    const order = await db.orders.create({
      userId, items,
      total: calculateTotal(items),
      paymentId: payment.id,
    });
    return ok(order);
  } catch (cause) {
    return err({ type: 'DATABASE_ERROR', cause });
  }
}

Now the route handler becomes a simple pattern match:

// routes/orders.ts
app.post('/orders', async (req, res) => {
  const result = await createOrder(req.body.userId, req.body.items);

  if (result.success) {
    return res.status(201).json(result.value);
  }

  // Compiler forces us to handle EVERY error variant
  switch (result.error.type) {
    case 'NOT_FOUND':
      return res.status(404).json({ error: result.error.message });
    case 'OUT_OF_STOCK':
      return res.status(400).json({
        error: 'Some items are unavailable',
        unavailableIds: result.error.itemIds,
      });
    case 'PAYMENT_FAILED':
      return res.status(402).json({ error: result.error.reason });
    case 'DATABASE_ERROR':
      console.error('Database error during order creation:', result.error.cause);
      return res.status(500).json({ error: 'Internal server error' });
  }
});

Three things improved immediately:

  1. Every failure path is documented in the OrderError discriminated union. You can read all possible failures without opening the implementation.

  2. The compiler enforces exhaustive handling. If you add a new error variant to OrderError but forget to handle it in the switch statement, TypeScript will produce a compile error with the never type check.

  3. Error context is never lost. Each error variant carries structured data -- the payment failure reason, the unavailable item IDs, the database error cause -- that the handler can use to produce precise responses or trigger recovery logic.

Composition: Chaining Operations Without Nesting

The naive Result pattern works for a single function call, but real applications chain multiple operations. Without composition helpers, you end up with nested if-checks that are just as bad as try/catch:

async function placeOrder(userId: string, items: Item[]) {
  const userResult = await findUser(userId);
  if (!userResult.success) return userResult;

  const stockResult = await checkStock(items);
  if (!stockResult.success) return stockResult;

  const paymentResult = await chargeCard(userResult.value, stockResult.value);
  if (!paymentResult.success) return paymentResult;

  const orderResult = await saveOrder(userResult.value, paymentResult.value);
  if (!orderResult.success) return orderResult;

  return ok(orderResult.value);
}

This is the "Result pyramid of doom." The solution is a composition helper that chains operations only when the previous one succeeds:

// result.ts (continued)
export async function andThen<T, E, U>(
  result: Promise<Result<T, E>>,
  fn: (value: T) => Promise<Result<U, E>>
): Promise<Result<U, E>> {
  const r = await result;
  if (!r.success) return r;
  return fn(r.value);
}

export async function map<T, E, U>(
  result: Promise<Result<T, E>>,
  fn: (value: T) => U
): Promise<Result<U, E>> {
  const r = await result;
  if (!r.success) return r;
  return ok(fn(r.value));
}

Now the order pipeline reads linearly:

async function placeOrder(userId: string, items: Item[]): Promise<Result<Order, OrderError>> {
  const user = await findUser(userId);

  const withStock = await andThen(Promise.resolve(user), async (u) => {
    const stock = await checkStock(items);
    return map(Promise.resolve(stock), (s) => ({ user: u, stock: s }));
  });

  const withPayment = await andThen(Promise.resolve(withStock), async (ctx) => {
    const payment = await chargeCard(ctx.user.stripeId, calculateTotal(items));
    return map(Promise.resolve(payment), (p) => ({ ...ctx, payment: p }));
  });

  return andThen(Promise.resolve(withPayment), (ctx) =>
    saveOrder(ctx.user.id, items, ctx.payment.id)
  );
}

This is cleaner but still verbose. This is where a proper library like neverthrow shines.

Production Pattern: neverthrow

neverthrow is a lightweight library (3.6KB gzipped) that provides a full Result and ResultAsync type with built-in composition methods. It has zero dependencies and works with any TypeScript project.

Install it:

npm install neverthrow

Here is the same order pipeline with neverthrow:

import { ok, err, Result, ResultAsync, okAsync, errAsync } from 'neverthrow';

// Define error types as a discriminated union
type AppError =
  | { type: 'NOT_FOUND'; entity: string; id: string }
  | { type: 'VALIDATION_ERROR'; field: string; message: string }
  | { type: 'PAYMENT_ERROR'; code: string; declineReason?: string }
  | { type: 'DB_ERROR'; operation: string; cause: unknown };

// Wrap an existing async function that might throw
function safeFindUser(userId: string): ResultAsync<User, AppError> {
  return ResultAsync.fromPromise(
    db.users.findById(userId),
    (cause) => ({ type: 'DB_ERROR' as const, operation: 'findUser', cause })
  ).andThen((user) => {
    if (!user) return err({ type: 'NOT_FOUND', entity: 'User', id: userId });
    return ok(user);
  });
}

function validateItems(items: unknown): Result<Item[], AppError> {
  if (!Array.isArray(items)) {
    return err({ type: 'VALIDATION_ERROR', field: 'items', message: 'Must be an array' });
  }
  if (items.length === 0) {
    return err({ type: 'VALIDATION_ERROR', field: 'items', message: 'Cannot be empty' });
  }
  for (const item of items) {
    if (!item.id || typeof item.quantity !== 'number') {
      return err({
        type: 'VALIDATION_ERROR',
        field: 'items',
        message: 'Each item must have id (string) and quantity (number)',
      });
    }
  }
  return ok(items as Item[]);
}

function safeCharge(
  stripeId: string,
  amount: number
): ResultAsync<PaymentResult, AppError> {
  return ResultAsync.fromPromise(
    paymentService.charge(stripeId, amount),
    (cause) => ({ type: 'PAYMENT_ERROR', code: 'CHARGE_FAILED', declineReason: String(cause) })
  ).andThen((payment) => {
    if (payment.status !== 'succeeded') {
      return err({
        type: 'PAYMENT_ERROR',
        code: 'DECLINED',
        declineReason: payment.failureReason,
      });
    }
    return ok(payment);
  });
}

function safeInsertOrder(
  userId: string,
  items: Item[],
  paymentId: string
): ResultAsync<Order, AppError> {
  return ResultAsync.fromPromise(
    db.orders.create({ userId, items, paymentId }),
    (cause) => ({ type: 'DB_ERROR', operation: 'insertOrder', cause })
  );
}

// The orchestration: chain operations with .andThen()
function placeOrder(
  userId: string,
  items: unknown
): ResultAsync<Order, AppError> {
  return validateItems(items)
    .asyncMap((validItems) => validItems) // lift sync Result into async
    .andThen((validItems) => {
      return safeFindUser(userId).andThen((user) => {
        const total = validItems.reduce((sum, i) => sum + i.quantity * getItemPrice(i.id), 0);
        return safeCharge(user.stripeId, total).andThen((payment) => {
          return safeInsertOrder(user.id, validItems, payment.id);
        });
      });
    });
}

The Express handler becomes a single .match() call that guarantees exhaustive error handling:

app.post('/orders', async (req, res) => {
  const result = await placeOrder(req.body.userId, req.body.items);

  result.match(
    (order) => res.status(201).json(order),
    (error) => {
      switch (error.type) {
        case 'NOT_FOUND':
          return res.status(404).json({ error: `${error.entity} ${error.id} not found` });
        case 'VALIDATION_ERROR':
          return res.status(400).json({ error: error.message, field: error.field });
        case 'PAYMENT_ERROR':
          return res.status(402).json({ error: error.declineReason ?? 'Payment failed' });
        case 'DB_ERROR':
          console.error(`DB error in ${error.operation}:`, error.cause);
          return res.status(500).json({ error: 'Internal server error' });
      }
    }
  );
});

The .match() method is the killer feature. You pass two callbacks -- one for success, one for failure. The failure callback receives the error typed as AppError, and the compiler enforces that your switch statement covers every variant. If you add a fifth error type to AppError six months later and forget to update this handler, your build fails.

Combining Multiple Results: The combine Pattern

In real applications, you often need to run multiple independent operations and only proceed if all of them succeed. neverthrow provides a combine utility:

import { combine } from 'neverthrow';

function processOrdersForBatch(userIds: string[]): ResultAsync<BatchResult, AppError> {
  // Run all user lookups in parallel
  const userResults = userIds.map((id) => safeFindUser(id));

  return combine(userResults)  // ResultAsync<User[], AppError>
    .andThen((users) => {
      // All users found. Now check inventory for all their items.
      const stockChecks = users.map((user) =>
        checkStockForUser(user.id)
      );
      return combine(stockChecks);
    })
    .andThen((stockResults) => {
      // All stock checks passed. Create orders.
      const orderCreates = stockResults.map(({ user, items }) =>
        safeInsertOrder(user.id, items, generatePaymentId())
      );
      return combine(orderCreates);
    })
    .map((orders) => ({
      processed: orders.length,
      orderIds: orders.map((o) => o.id),
    }));
}

combine returns the FIRST error it encounters (fail-fast semantics), which is usually what you want for ordered operations. If you need all errors, use combineWithAllErrors:

import { combineWithAllErrors } from 'neverthrow';

// Returns Result<T[], E[]> -- collects ALL failures, not just the first
const results = await combineWithAllErrors([
  validateEmail(input.email),
  validatePassword(input.password),
  validateAge(input.age),
]);

results.match(
  () => { /* all valid */ },
  (errors) => {
    // errors is an array of ALL validation failures
    return res.status(400).json({
      errors: errors.map((e) => ({ field: e.field, message: e.message })),
    });
  }
);

Pattern: Railway-Oriented Middleware

Once your service layer returns Result types, you can build Express/Koa/Fastify middleware that integrates natively. Here is a generic middleware that unwraps Result into HTTP responses:

// middleware/result-handler.ts
import { Request, Response, NextFunction } from 'express';
import { Result } from 'neverthrow';

export function resultHandler<E extends { type: string }>(
  statusMap: Record<E['type'], number>,
  logger?: (error: E) => void
) {
  return (result: Result<unknown, E>, req: Request, res: Response, next: NextFunction) => {
    result.match(
      (value) => {
        // Success -- pass the value downstream via res.locals
        res.locals.result = value;
        next();
      },
      (error) => {
        logger?.(error);
        const status = statusMap[error.type] ?? 500;
        return res.status(status).json({ error });
      }
    );
  };
}

Usage:

import { resultHandler } from './middleware/result-handler';

const orderStatusMap = {
  NOT_FOUND: 404,
  VALIDATION_ERROR: 400,
  PAYMENT_ERROR: 402,
  DB_ERROR: 500,
} as const;

app.post('/orders', async (req, res, next) => {
  const result = await placeOrder(req.body.userId, req.body.items);

  const handler = resultHandler(orderStatusMap, (err) => {
    if (err.type === 'DB_ERROR') {
      console.error('Order DB error:', { operation: err.operation, cause: err.cause });
    }
  });

  handler(result, req, res, next);
}, (req, res) => {
  // This only runs on success -- res.locals.result contains the Order
  return res.status(201).json(res.locals.result);
});

This pattern eliminates the duplicated error-to-status-code mapping that otherwise appears in every route handler. Define your error-to-status map once per domain, and the middleware handles the rest.

Advanced Pattern: Error Recovery and Retry

One advantage of errors as values is that recovery logic becomes composable. Here is a retry wrapper for transient errors:

import { ResultAsync, errAsync } from 'neverthrow';

function withRetry<T, E extends { type: string }>(
  operation: () => ResultAsync<T, E>,
  options: {
    maxAttempts: number;
    retryableErrors: E['type'][];
    backoffMs: number;
  }
): ResultAsync<T, E> {
  return operation().orElse((error) => {
    if (
      options.maxAttempts <= 1 ||
      !options.retryableErrors.includes(error.type)
    ) {
      return errAsync(error); // Non-retryable or out of attempts
    }
    return new ResultAsync(
      new Promise((resolve) => {
        setTimeout(() => {
          resolve(
            withRetry(operation, {
              maxAttempts: options.maxAttempts - 1,
              retryableErrors: options.retryableErrors,
              backoffMs: options.backoffMs * 2, // exponential backoff
            })
          );
        }, options.backoffMs);
      })
    );
  });
}

Usage with a payment charge (which can fail transiently):

const paymentResult = await withRetry(
  () => safeCharge(user.stripeId, total),
  {
    maxAttempts: 3,
    retryableErrors: ['PAYMENT_ERROR'], // Only retry payment errors, not DB errors
    backoffMs: 1000,
  }
);

The type system ensures you can only retry error types that actually exist in your AppError union. If you mistype 'PAYMENT_ERRROR', the compiler catches it.

Beyond neverthrow: Effect-TS for Complex Workflows

neverthrow is perfect for 90% of use cases. But when your application has deeply nested dependencies -- database connections, message queues, file systems, third-party APIs -- you start needing three things that neverthrow does not provide:

  1. Dependency injection: The ability to swap implementations (e.g., a real Stripe client vs. a test double) without threading parameters through every function.

  2. Multiple error channels: The ability to distinguish between "expected" errors (user not found, validation failure) and "unexpected" defects (out of memory, null pointer) without a single giant discriminated union.

  3. Resource management: The ability to acquire and safely release resources (database connections, file handles) even when errors occur mid-pipeline.

Effect-TS provides all three. It is a more ambitious library (~50KB gzipped) that brings the full power of algebraic effects to TypeScript. Here is the key distinction:

Concept neverthrow Effect-TS
Error channel Single E type Effect<A, E, R> -- separate channels for expected errors (E) and unexpected defects (Cause)
Dependencies Manual threading R type parameter -- declared at the type level, provided by runtime layer
Resource safety Manual try/finally Effect.acquireRelease -- guaranteed cleanup
Concurrency Manual Promise.all Built-in fibers with structured concurrency
Bundle size 3.6KB ~50KB (tree-shakeable)

Here is a practical Effect-TS example that would be painful in neverthrow:

import { Effect, pipe, Console, Schedule } from 'effect';

// Declare service interfaces as branded types (never constructed at runtime)
class PaymentGateway extends Effect.Tag('PaymentGateway')<
  PaymentGateway,
  {
    charge(amount: number): Effect.Effect<ChargeResult, PaymentError>;
    refund(chargeId: string): Effect.Effect<void, PaymentError>;
  }
>() {}

class Database extends Effect.Tag('Database')<
  Database,
  {
    query<T>(sql: string, params: unknown[]): Effect.Effect<T[], DbError>;
    transaction<T>(eff: Effect.Effect<T>): Effect.Effect<T, DbError>;
  }
>() {}

// The order creation workflow -- only declares WHAT it needs, not HOW to get it
function createOrder(
  userId: string,
  items: Item[]
): Effect.Effect<
  Order,                          // Success type
  PaymentError | DbError | NotFoundError,  // Expected errors (typed)
  PaymentGateway | Database        // Required services (injected at runtime)
> {
  return pipe(
    Database.query<User>('SELECT * FROM users WHERE id = $1', [userId]),
    Effect.flatMap((users) => {
      if (users.length === 0) {
        return Effect.fail(new NotFoundError({ entity: 'User', id: userId }));
      }
      const user = users[0];
      const total = calculateTotal(items);

      return pipe(
        PaymentGateway.charge(total),
        // Retry payment up to 3 times with exponential backoff
        Effect.retry(
          Schedule.exponential('1 second').pipe(
            Schedule.compose(Schedule.recurs(3))
          )
        ),
        Effect.flatMap((charge) =>
          Database.query<Order>(
            'INSERT INTO orders (user_id, total, payment_id) VALUES ($1, $2, $3) RETURNING *',
            [user.id, total, charge.id]
          ).pipe(Effect.map((orders) => orders[0]))
        )
      );
    }),
    // Wrap everything in a transaction
    Effect.scoped // ensures resources are released
  );
}

// At the edge of the application, provide real implementations:
import { Effect, Layer } from 'effect';

const PaymentGatewayLive = Layer.succeed(PaymentGateway, {
  charge: (amount) =>
    Effect.tryPromise({
      try: () => stripe.charges.create({ amount, currency: 'usd' }),
      catch: (cause) => new PaymentError({ cause }),
    }),
  refund: (chargeId) =>
    Effect.tryPromise({
      try: () => stripe.refunds.create({ charge: chargeId }),
      catch: (cause) => new PaymentError({ cause }),
    }),
});

const DatabaseLive = Layer.succeed(Database, {
  query: (sql, params) =>
    Effect.tryPromise({
      try: () => pool.query(sql, params),
      catch: (cause) => new DbError({ cause }),
    }),
  transaction: (eff) =>
    Effect.tryPromise({
      try: async () => {
        await pool.query('BEGIN');
        try {
          const result = await Effect.runPromise(eff);
          await pool.query('COMMIT');
          return result;
        } catch (e) {
          await pool.query('ROLLBACK');
          throw e;
        }
      },
      catch: (cause) => new DbError({ cause }),
    }),
});

// Wire up and run
const program = Effect.provide(
  createOrder('user_123', [{ id: 'item_1', quantity: 2 }]),
  Layer.mergeAll(PaymentGatewayLive, DatabaseLive)
);

// At the HTTP boundary:
const result = await Effect.runPromise(program);

// Or, for an Express handler that catches defects too:
import { Exit } from 'effect';

app.post('/orders', async (req, res) => {
  const exit = await Effect.runPromiseExit(
    createOrder(req.body.userId, req.body.items).pipe(
      Effect.provide(Layer.mergeAll(PaymentGatewayLive, DatabaseLive))
    )
  );

  Exit.match(exit, {
    onSuccess: (order) => res.status(201).json(order),
    onFailure: (cause) => {
      const failure = Cause.failureOption(cause);
      if (Effect.isFailure(failure)) {
        switch (failure._tag) {
          case 'NotFoundError':
            return res.status(404).json({ error: failure.message });
          case 'PaymentError':
            return res.status(402).json({ error: failure.message });
          case 'DbError':
            console.error('Database failure:', Cause.pretty(cause));
            return res.status(500).json({ error: 'Internal server error' });
        }
      }
      // Unexpected defect (bug, not an expected error)
      console.error('Unexpected defect:', Cause.pretty(cause));
      return res.status(500).json({ error: 'Internal server error' });
    },
  });
});

The key insight with Effect-TS is that the function signature is the contract. createOrder does not import Stripe or a database pool. It declares its dependencies in the R type parameter. You provide them at the edge of the application via Layer. This means:

  • Testing is trivial: inject a test layer that returns mock data. No jest.mock(), no module patching.
const TestPaymentGateway = Layer.succeed(PaymentGateway, {
  charge: () => Effect.succeed({ id: 'ch_test', status: 'succeeded' }),
  refund: () => Effect.void,
});

const result = await Effect.runPromise(
  program.pipe(Effect.provide(TestPaymentGateway))
);
  • Error recovery is explicit: Effect.retry, Effect.catchTag, Effect.catchAll, and Effect.tapError give you fine-grained control over how each error type is handled.

  • Resources are guaranteed to release: Effect.acquireRelease ensures database connections, file handles, and network sockets are always cleaned up, even on unexpected defects. This eliminates an entire class of production bugs (connection leaks, file descriptor exhaustion).

When to Use Which Approach

After deploying these patterns across multiple production systems, here is a practical decision framework:

Start with discriminated union errors in return types (the basic Result pattern) when:

  • You have a team new to functional programming concepts
  • Your error types are simple (fewer than 10 variants)
  • You do not need dependency injection for testing
  • Bundle size matters (serverless functions, edge runtimes)

Use neverthrow when:

  • You want .andThen(), .map(), .match(), and combine without writing them yourself
  • You need to wrap existing Promise-based APIs
  • Your team is comfortable with type-level programming
  • You want the smallest possible dependency

Use Effect-TS when:

  • Your application has complex dependency graphs (database, cache, message queue, multiple third-party APIs)
  • You need guaranteed resource cleanup (database connections, file handles)
  • You want structured concurrency (fibers instead of raw promises)
  • Your team buys into the "effect system" paradigm and is willing to invest in learning it
  • You are building a system where correctness is critical (payments, healthcare, finance)

Common Pitfalls

1. Mixing Exceptions and Result Types

The worst of both worlds is wrapping every Result-returning function in try/catch "just in case":

// BAD: defeats the purpose
app.post('/orders', async (req, res) => {
  try {
    const result = await placeOrder(req.body.userId, req.body.items);
    result.match(
      (order) => res.status(201).json(order),
      (error) => res.status(mapErrorToStatus(error)).json({ error })
    );
  } catch (err) {
    res.status(500).json({ error: 'Something went wrong' });
  }
});

If your Result-returning functions can still throw, you have not actually adopted the pattern. Every function that might throw must be wrapped in ResultAsync.fromPromise() (neverthrow) or Effect.tryPromise() (Effect-TS) at the boundary where the exception originates. Never let an exception escape into Result-land.

2. Overly Generic Error Types

// BAD: loses all error discrimination
type AppError = { message: string };

The whole point is to let the compiler enforce exhaustive handling. If every error is just { message: string }, you are back to the try/catch problem. Use discriminated unions:

// GOOD: compiler-enforced exhaustive handling
type AppError =
  | { _tag: 'NotFound'; entity: string; id: string }
  | { _tag: 'ValidationError'; errors: { field: string; message: string }[] }
  | { _tag: 'PaymentError'; code: string }
  | { _tag: 'RateLimit'; retryAfter: number };

3. Forgetting to Handle the Error at the Edge

A Result type does nothing if you call .match() and only handle the success case:

// BAD: swallowing errors
result.match(
  (value) => console.log('Success:', value),
  () => {}  // Silent error swallowing -- worse than a crash
);

Always propagate to the user or log with full context:

// GOOD: logs error context, then returns a user-safe message
result.match(
  (value) => res.json(value),
  (error) => {
    logger.error('Order creation failed', { error, userId: req.user?.id });
    res.status(statusFromError(error)).json({
      error: userFacingMessage(error),
      reference: generateErrorReference(), // for support to look up
    });
  }
);

4. Deeply Nested andThen Chains

As you chain more than 3-4 operations, consider extracting named intermediate functions or using Effect-TS's pipe with gen:

// Effect-TS gen: write imperative-looking code with full type safety
const program = Effect.gen(function* (_) {
  const user = yield* _(findUser(userId));
  const stock = yield* _(checkStock(items));
  const payment = yield* _(chargeCard(user.stripeId, calculateTotal(items)));
  const order = yield* _(saveOrder(user.id, items, payment.id));
  return order;
});

The yield* _() pattern in Effect-TS gives you the readability of async/await with full typed error tracking. If findUser can fail with NotFoundError | DbError, the compiler knows the entire program can fail with those same types.

5. Not Versioning Your Error Types

Error types are part of your API contract if you share them between services. Use a shared package and version it:

// packages/errors/src/v2.ts
export type OrderErrorV2 =
  | { _tag: 'NotFound'; entity: string; id: string }
  | { _tag: 'ValidationError'; fields: FieldError[] }  // renamed from 'errors'
  | { _tag: 'PaymentError'; code: string; processorRaw?: unknown }  // new optional field
  | { _tag: 'RateLimit'; retryAfter: number; quotaRemaining: number };  // new required field

This prevents a service that adds a required field from breaking callers that haven't updated yet.

Testing with Result Types

One of the biggest wins of this pattern is testability. Here is how you test the order creation pipeline:

import { describe, it, expect } from 'vitest';
import { ok, err } from 'neverthrow';

// With neverthrow -- no mocking framework needed
describe('placeOrder', () => {
  it('returns NOT_FOUND when user does not exist', async () => {
    // Arrange: stub the function directly
    const findUser = () => Promise.resolve(err({ type: 'NOT_FOUND', entity: 'User', id: '1' }));

    // Act
    const result = await placeOrder('1', [{ id: 'item_1', quantity: 1 }]);

    // Assert
    expect(result.isErr()).toBe(true);
    expect(result._unsafeUnwrapErr()).toEqual({
      type: 'NOT_FOUND',
      entity: 'User',
      id: '1',
    });
  });

  it('charges the correct amount', async () => {
    let chargedAmount = 0;

    // Arrange: stub payment to capture the charged amount
    const chargeCard = (stripeId: string, amount: number) => {
      chargedAmount = amount;
      return Promise.resolve(ok({ id: 'ch_123', status: 'succeeded' }));
    };

    // Act
    await placeOrder('1', [
      { id: 'item_1', quantity: 2 },  // $10 each = $20
      { id: 'item_2', quantity: 1 },  // $15 each = $15
    ]);

    // Assert
    expect(chargedAmount).toBe(35);
  });
});

No mocking library. No module patching. Just pass functions that return Result values. This is the "functional core, imperative shell" architecture: your business logic is pure functions that return data, and the side effects live at the boundary.

Conclusion

The Result type pattern is not a novelty -- it is how languages like Rust, Go, and Swift handle errors by default. TypeScript gives us the type system to express it, and libraries like neverthrow and Effect-TS provide the ergonomics to make it pleasant.

Here is what you get when you adopt this pattern in production:

  1. The compiler is your QA engineer. Every unhandled error path is a type error. No more runtime surprises from exceptions you forgot to catch.

  2. Function signatures tell the truth. findUser: (id: string) => Result<User, NotFoundError | DbError> documents every failure path without reading the implementation.

  3. Error handling composes. Retry logic, fallback values, error transformation -- all expressed as composable functions, not nested try/catch blocks.

  4. Testing is mechanical. Inject stub functions that return ok() or err() values. No mocking framework required.

  5. Onboarding is faster. New team members can see every possible error by reading the type definition. No tribal knowledge about which functions "might sometimes throw."

Start small. Pick one module -- authentication, payment processing, or order management -- and refactor it to return Result types. Add neverthrow when the boilerplate annoys you. Graduate to Effect-TS when your dependency graph outgrows manual threading.

The best time to adopt this pattern was before your last production outage. The second best time is your next pull request.

Further Reading

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment