Every TypeScript developer has written something like this:

function getUser(id: string): User {
  const user = db.findUser(id);
  if (!user) throw new Error("User not found");
  return user;
}

Enter fullscreen mode Exit fullscreen mode

The function signature says it returns a User. But that's a lie. It might throw, and nothing in the type system warns you about it. The caller has to remember to wrap it in try-catch, hope they handle the right error type, and accept that the compiler won't help if they forget.

This is the fundamental problem with exception-based error handling in TypeScript: errors are invisible in function signatures. They travel through a hidden channel that the type system cannot track.

There is a better way.

The Problem with try-catch

Consider a typical API call:

async function fetchUserOrders(userId: string) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    const enriched = orders.map((order) => ({
      ...order,
      userName: user.name,
    }));
    return enriched;
  } catch (error) {
    // What type is `error`? We don't know.
    // Did fetchUser throw? Or fetchOrders?
    // Is it a network error? A validation error? A parsing error?
    console.error("Something went wrong:", error);
    return [];
  }
}

Enter fullscreen mode Exit fullscreen mode

Three problems stand out:

  1. Lost type information. The catch block receives unknown. You have to guess or use instanceof chains to figure out what went wrong.
  2. Invisible failure modes. Nothing in the function signature indicates it can fail. Callers see Promise<Order[]> and might not realize they need error handling.
  3. Broken composition. You cannot chain operations cleanly. Every step might throw, so you either nest try-catch blocks or lump everything into one, losing granularity.

Either: Type-Safe Errors for Sync Operations

Either<E, A> is a type that explicitly represents a computation that can succeed with a value of type A or fail with an error of type E. The error type is right there in the signature — the compiler enforces it.

import * as E from "@oofp/core/either";

type ValidationError = { field: string; message: string };

const validateAge = (input: string): E.Either<ValidationError, number> => {
  const n = Number(input);
  if (isNaN(n)) return E.left({ field: "age", message: "Not a number" });
  if (n < 0 || n > 150) return E.left({ field: "age", message: "Out of range" });
  return E.right(n);
};

Enter fullscreen mode Exit fullscreen mode

The return type Either<ValidationError, number> tells you everything: this function might fail with a ValidationError, or succeed with a number. No surprises. No hidden exceptions.

Composing with pipe

The real power of Either emerges when you compose operations using pipe:

import { pipe } from "@oofp/core/pipe";
import * as E from "@oofp/core/either";

type ParseError = string;

const parseNumber = (s: string): E.Either<ParseError, number> => {
  const n = Number(s);
  return isNaN(n) ? E.left(`"${s}" is not a number`) : E.right(n);
};

const ensurePositive = (n: number): E.Either<ParseError, number> =>
  n > 0 ? E.right(n) : E.left("Must be positive");

const ensureBelow = (max: number) => (n: number): E.Either<ParseError, number> =>
  n < max ? E.right(n) : E.left(`Must be below ${max}`);

const parsePercentage = (input: string) =>
  pipe(
    parseNumber(input),
    E.chain(ensurePositive),
    E.chain(ensureBelow(100)),
  );

parsePercentage("42");    // Right(42)
parsePercentage("-5");    // Left("Must be positive")
parsePercentage("abc");   // Left("\"abc\" is not a number")
parsePercentage("150");   // Left("Must be below 100")

Enter fullscreen mode Exit fullscreen mode

Each step in the pipeline short-circuits on error. If parseNumber fails, ensurePositive never runs. The error propagates automatically, and its type is tracked at every step.

Validating structured data

Use sequenceObject to validate multiple fields at once:

import { pipe } from "@oofp/core/pipe";
import * as E from "@oofp/core/either";

interface UserForm {
  name: string;
  email: string;
  age: number;
}

const validateForm = (data: Record<string, string>): E.Either<string, UserForm> =>
  E.sequenceObject({
    name: pipe(
      data.name,
      E.fromNullable("Name is required"),
      E.chain((s) => s.length >= 2 ? E.right(s) : E.left("Name too short")),
    ),
    email: pipe(
      data.email,
      E.fromNullable("Email is required"),
      E.chain((s) => s.includes("@") ? E.right(s) : E.left("Invalid email")),
    ),
    age: pipe(
      data.age,
      E.fromNullable("Age is required"),
      E.chain(parseNumber),
      E.chain(ensurePositive),
    ),
  });

Enter fullscreen mode Exit fullscreen mode

The result is either a fully validated UserForm or the first validation error. The compiler knows exactly what you'll get.

TaskEither: Async Error Handling

Most real applications involve async operations — API calls, database queries, file I/O. TaskEither<E, A> extends Either into the async world:

type TaskEither<E, A> = () => Promise<Either<E, A>>;

Enter fullscreen mode Exit fullscreen mode

It's a lazy function that, when executed, returns a Promise that always resolves to an Either. No rejections, no unhandled promises — just typed success or failure.

import * as TE from "@oofp/core/task-either";
import { pipe } from "@oofp/core/pipe";

interface HttpError {
  status: number;
  message: string;
}

interface User {
  id: string;
  name: string;
  email: string;
}

const fetchUser = (id: string): TE.TaskEither<HttpError, User> =>
  pipe(
    () => fetch(`/api/users/${id}`).then(async (res) => {
      if (!res.ok) throw { status: res.status, message: res.statusText };
      return res.json() as Promise<User>;
    }),
    TE.tryCatch((err) => err as HttpError),
  );

Enter fullscreen mode Exit fullscreen mode

tryCatch wraps the fallible async operation. If the promise rejects, the error is caught and mapped to your typed error via the callback. The resulting TaskEither never rejects.

Building async pipelines

Chain async operations the same way you chain sync ones:

interface Order {
  id: string;
  userId: string;
  total: number;
}

const fetchOrders = (userId: string): TE.TaskEither<HttpError, Order[]> =>
  pipe(
    () => fetch(`/api/orders?userId=${userId}`).then(async (res) => {
      if (!res.ok) throw { status: res.status, message: res.statusText };
      return res.json() as Promise<Order[]>;
    }),
    TE.tryCatch((err) => err as HttpError),
  );

const getUserWithOrders = (userId: string) =>
  pipe(
    fetchUser(userId),
    TE.chain((user) =>
      pipe(
        fetchOrders(user.id),
        TE.map((orders) => ({ user, orders })),
      ),
    ),
  );

Enter fullscreen mode Exit fullscreen mode

If fetchUser fails, fetchOrders never executes. The error type flows through the entire pipeline.

Error recovery

Use orElse or chainLeft to recover from errors:

const fetchUserWithFallback = (id: string) =>
  pipe(
    fetchUser(id),
    TE.orElse((err) =>
      err.status === 404
        ? TE.of({ id, name: "Guest", email: "" })
        : TE.left(err),
    ),
  );

Enter fullscreen mode Exit fullscreen mode

Collecting independent data

Use sequenceObject to combine multiple operations into a single result:

const getUserDashboard = (id: string) =>
  TE.sequenceObject({
    user: fetchUser(id),
    orders: fetchOrders(id),
    settings: fetchSettings(id),
  });
// TaskEither<HttpError, { user: User; orders: Order[]; settings: Settings }>

Enter fullscreen mode Exit fullscreen mode

Each operation runs sequentially. If any fails, execution stops and you get the first error. Need parallelism? Use concurrencyObject instead:

const getUserDashboard = (id: string) =>
  TE.concurrencyObject()({
    user: fetchUser(id),
    orders: fetchOrders(id),
    settings: fetchSettings(id),
  });

Enter fullscreen mode Exit fullscreen mode

Resilience with retry

const fetchUserReliable = (id: string) =>
  pipe(
    fetchUser(id),
    TE.retry({
      maxRetries: 3,
      delay: 1000,
      skipIf: (err) => err.status === 404, // don't retry "not found"
      onError: (err) => console.warn(`Retry: ${err.message}`),
    }),
  );

Enter fullscreen mode Exit fullscreen mode

Bridging to the Outside World

At the boundaries of your application — HTTP handlers, React components, CLI entry points — you need to extract the Either and produce a concrete result:

import * as TE from "@oofp/core/task-either";
import { pipe } from "@oofp/core/pipe";

// Express handler
app.get("/users/:id", async (req, res) => {
  const result = await pipe(
    getUserWithOrders(req.params.id),
    TE.fold(
      (err) => ({ status: err.status, body: { error: err.message } }),
      (data) => ({ status: 200, body: data }),
    ),
    (task) => task(),
  );

  res.status(result.status).json(result.body);
});

Enter fullscreen mode Exit fullscreen mode

fold collapses both branches into a single type. No try-catch anywhere. The error type is explicit, the happy path is clean, and the compiler ensures you handle both cases.

Why This Matters

Switching from try-catch to Either and TaskEither gives you three concrete advantages:

Type safety. Every function signature declares its failure modes. If a function returns TaskEither<HttpError | ValidationError, User>, you know exactly what can go wrong. The compiler enforces exhaustive handling.

Composability. Operations compose with pipe, chain, and map. You build complex pipelines from small, testable functions. Error propagation is automatic — no manual try-catch threading.

Testability. Functions that return Either are pure. They don't throw, they don't have hidden side effects, and they're trivial to test:

import { expect, test } from "vitest";
import * as E from "@oofp/core/either";

test("parsePercentage rejects negative numbers", () => {
  const result = parsePercentage("-5");
  expect(E.isLeft(result)).toBe(true);
  expect(result).toEqual(E.left("Must be positive"));
});

Enter fullscreen mode Exit fullscreen mode

No mocking try-catch, no asserting that something throws — just straightforward input-output testing.

Getting Started

Install @oofp/core and start with the building blocks:

npm install @oofp/core

Enter fullscreen mode Exit fullscreen mode

The patterns scale from small validations to full application architectures. Start by replacing one try-catch block with Either, and the benefits will compound as you build on top of it.