Result<T, E> in TypeScript: A Better Alternative to Throwing

Exceptions still have a place, but they are often too invisible for everyday application logic. A Result<T, E> type makes success and failure explicit, easier to compose, and much friendlier to TypeScript’s type system.

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

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return { ok: false, error: "Cannot divide by zero" };
  }
  return { ok: true, value: a / b };
}

Why not just throw?

JavaScript and TypeScript already have exceptions, so the first question is fair: why add another pattern at all?

The issue is not that exceptions are “bad.” The issue is that they are often implicit. A function signature like getUser(id: string): Promise<User> tells you what success looks like, but not what failure looks like. The function might:

  • throw a validation error,
  • throw a network error,
  • return null,
  • or swallow the failure and log something quietly.

This ambiguity spreads fast in larger codebases. Callers must remember behavior instead of seeing it in the types.

A Result<T, E> makes failure part of the function contract. That alone improves readability and reduces “surprise exceptions.”

What is Result<T, E>?

A Result type is a discriminated union with two possible states:

  • Success, containing a value of type T
  • Failure, containing an error of type E
type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

TypeScript narrows the union automatically when you check ok:

const result = divide(10, 2);

if (result.ok) {
  console.log(result.value); // number
} else {
  console.error(result.error); // string
}

That gives you two big wins:

  1. Callers must handle both paths explicitly.
  2. The compiler helps keep that handling honest.

Building a simple Result type

Start small. You do not need a heavy functional programming library to get the value of this pattern.

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

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

Those helper constructors reduce repetition and make return statements clearer:

function parsePort(input: string): Result<number, string> {
  const port = Number(input);

  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
    return err("Invalid port number");
  }

  return ok(port);
}
This style shines when invalid input is expected and common. Validation errors are usually not exceptional enough to justify throwing.

Refactoring from exceptions to Result

Here is a typical “throwing” example:

function parseJson(text: string): unknown {
  return JSON.parse(text);
}

function getUserName(json: string): string {
  const data = parseJson(json) as { user?: { name?: string } };

  if (!data.user?.name) {
    throw new Error("Missing user name");
  }

  return data.user.name;
}

This works, but it mixes normal business rules with exception flow. Now refactor it with Result:

type ParseError =
  | { type: "InvalidJson"; message: string }
  | { type: "MissingUserName"; message: string };

function parseJson(text: string): Result<unknown, ParseError> {
  try {
    return ok(JSON.parse(text));
  } catch {
    return err({
      type: "InvalidJson",
      message: "Input is not valid JSON"
    });
  }
}

function getUserName(json: string): Result<string, ParseError> {
  const parsed = parseJson(json);

  if (!parsed.ok) return parsed;

  const data = parsed.value as { user?: { name?: string } };

  if (!data.user?.name) {
    return err({
      type: "MissingUserName",
      message: "JSON does not contain user.name"
    });
  }

  return ok(data.user.name);
}

Notice what improved:

  • The failure modes are visible in the return type.
  • Errors are structured, not just free-form strings.
  • The caller decides how to respond.
const result = getUserName(payload);

if (!result.ok) {
  switch (result.error.type) {
    case "InvalidJson":
      console.error("Bad request:", result.error.message);
      break;
    case "MissingUserName":
      console.error("Validation error:", result.error.message);
      break;
  }
} else {
  console.log("User:", result.value);
}

Using Result with async code

Asynchronous code is where many teams feel the pain of mixed error styles the most. A function can reject its promise, resolve to an error object, return null, or throw before the promise is created. That inconsistency is a maintenance tax.

Instead, make the promise always resolve to a Result:

type User = {
  id: string;
  name: string;
};

type UserError =
  | { type: "NotFound"; message: string }
  | { type: "Network"; message: string }
  | { type: "Unknown"; message: string };

async function fetchUser(id: string): Promise<Result<User, UserError>> {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (response.status === 404) {
      return err({ type: "NotFound", message: "User not found" });
    }

    if (!response.ok) {
      return err({ type: "Network", message: "Request failed" });
    }

    const user = (await response.json()) as User;
    return ok(user);
  } catch {
    return err({ type: "Unknown", message: "Unexpected failure" });
  }
}

Now every caller gets one predictable pattern:

const result = await fetchUser("42");

if (!result.ok) {
  showToast(result.error.message);
  return;
}

renderProfile(result.value);
A helpful rule: for app-level operations, prefer Promise<Result<T, E>> over “sometimes throws, sometimes rejects, sometimes returns null.”

Modeling domain errors with real types

One of the strongest arguments for Result is that it pushes you toward better error design.

Compare these two signatures:

Weak

function createOrder(
  input: OrderInput
): Result<Order, string>

Easy to start with, but vague. Every consumer has to parse strings or rely on conventions.

Better

type OrderError =
  | { type: "OutOfStock"; sku: string }
  | { type: "InvalidQuantity" }
  | { type: "PaymentDeclined" };

function createOrder(
  input: OrderInput
): Result<Order, OrderError>

Much easier to handle safely in UI code, APIs, and logs.

Typed domain errors make it easier to:

  • map failures to HTTP status codes,
  • show user-friendly messages in the UI,
  • distinguish retryable vs non-retryable problems,
  • and keep analytics and logs consistent.

Useful helper functions

As your codebase grows, helper utilities make Result feel much smoother to use.

map

Transform the success value without touching the error.

function map<T, E, U>(
  result: Result<T, E>,
  fn: (value: T) => U
): Result<U, E> {
  return result.ok ? ok(fn(result.value)) : result;
}

mapError

Transform the error while keeping the success value intact.

function mapError<T, E, F>(
  result: Result<T, E>,
  fn: (error: E) => F
): Result<T, F> {
  return result.ok ? result : err(fn(result.error));
}

andThen

Chain operations that themselves return Result. This is especially useful for validation pipelines.

function andThen<T, E, U>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>
): Result<U, E> {
  return result.ok ? fn(result.value) : result;
}

Example:

function parseNumber(input: string): Result<number, string> {
  const n = Number(input);
  return Number.isNaN(n) ? err("Not a number") : ok(n);
}

function ensurePositive(n: number): Result<number, string> {
  return n > 0 ? ok(n) : err("Must be positive");
}

const result = andThen(parseNumber("42"), ensurePositive);

When should you still throw?

Result is great, but exceptions still matter. Do not turn this into ideology.

Throwing still makes sense when:

  • a failure is truly unrecoverable,
  • you hit an impossible state that indicates a bug,
  • a framework expects thrown errors,
  • or you are at a boundary where a crash is appropriate.
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${String(value)}`);
}

That is a good use of throwing. If your code reaches that point, the issue is probably not user input. It is a programming mistake.

A useful distinction: use Result for expected, modeled failures. Use throw for bugs, invariants, and truly exceptional conditions.

Practical guidelines for teams

  • Do not mix styles randomly. Pick conventions for each layer of your app.
  • Prefer structured error types over strings once the code matters to more than one caller.
  • Use Result heavily at boundaries like parsing, validation, network calls, and database adapters.
  • Convert exceptions at the edge into typed errors your app can reason about.
  • Keep error types small and purposeful. Do not make one giant “everything that can fail” union too early.
  • Avoid nesting Results too deeply. Use helpers like andThen to keep the flow readable.

A simple strategy that works well

  1. Inside low-level code, catch thrown exceptions from libraries.
  2. Convert them into typed Result errors.
  3. Pass those typed failures upward through your own app logic.
  4. At the UI or API boundary, map them to display messages or status codes.