TypeScript

A typed Result type in TypeScript

Return errors as values the compiler forces you to handle, instead of throwing exceptions that callers can silently forget.

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

function parseJSON<T>(raw: string): Result<T> {
  try {
    return { ok: true, value: JSON.parse(raw) as T };
  } catch (error) {
    return { ok: false, error: error as Error };
  }
}

const good = parseJSON<{ id: number }>('{"id":42}');
if (good.ok) {
  console.log(good.value.id); // 42, fully typed
}

const bad = parseJSON('{ broken');
if (!bad.ok) {
  console.log(bad.error.message); // a SyntaxError message
}
Output
42
Unexpected end of JSON input

Open this snippet in the editor

Launches a fresh code space with this TypeScript already loaded — edit it, share the link, or keep building.

The ShareCode editor — write or paste your code, then share the link to bring someone in.
Share a code space by link, QR code, email, social apps, or an embed snippet for your own site.

How it works

A `Result` is a discriminated union: the `ok` boolean is the discriminant, and TypeScript narrows the type the moment you check it. Inside `if (good.ok)`, the compiler knows `good.value` exists and `good.error` does not — and in the `else` branch it knows the opposite. You simply cannot read `value` without first proving success, and that guarantee is enforced at compile time.

The point is to push failure into the function's signature instead of hiding it in an invisible `throw`. A caller reading `Result<User>` sees immediately that the call can fail and that they're expected to handle it. With exceptions, that obligation is undocumented: nothing in `getUser(): User` tells you it might throw, so the handling gets forgotten until it crashes in production.

The pattern comes from Rust's `Result` and the ML family's `Either`, and it shines for parsing, validation, network calls, and file I/O — anywhere a caller realistically needs to deal with the error path rather than let it bubble up to a top-level handler. For truly unrecoverable programmer errors (a failed invariant, a bug), a plain `throw` is still the right tool; Result is for *expected* failures.

Used consistently, Result composes. A function can call several Result-returning helpers, short-circuit on the first failure, and return a Result of its own — so error handling reads as a straight line rather than a tangle of try/catch. The cost is discipline: a layer should either return Result or throw, not both, so callers always know what to expect.

Variations

Constructor helpers

Tiny ok() and err() helpers make Result-returning code read cleanly and keep the literal shape in one place.

TypeScript
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

function half(n: number): Result<number, string> {
  return n % 2 === 0 ? ok(n / 2) : err("not even");
}

Async Result

Wrap a promise so network failures become values instead of rejections the caller has to remember to catch.

TypeScript
async function getUser(id: string): Promise<Result<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
    return { ok: true, value: await res.json() };
  } catch (error) {
    return { ok: false, error: error as Error };
  }
}

A call site that has to handle both paths

Because the failure is in the type, the compiler won't let the caller reach the value without checking. That single constraint eliminates a whole category of "forgot to handle the error" bugs.

TypeScript
const result = await getUser("42");
if (!result.ok) {
  showToast(result.error.message);   // must handle this first
} else {
  renderProfile(result.value);       // value is guaranteed here
}

Common mistakes & good to know

  • Casting is a trust boundary: `JSON.parse(raw) as T` assumes the input matches T. For untrusted data, validate the shape (e.g. with Zod) before claiming the type.
  • Always check `ok` before touching value or error — skipping it defeats the pattern, and the union won't narrow so the compiler stops you anyway.
  • Don't mix styles in one layer. Decide whether a module returns Result or throws and keep it consistent, so callers know what to expect.
  • Result is for expected failures. For genuine bugs and broken invariants, a throw is still clearer than threading a Result nobody can recover from.

Frequently asked questions

Why not just throw exceptions?

Exceptions are invisible in the type signature, so callers can forget to handle them until it crashes. A Result puts the failure in the return type, and TypeScript forces you to check before using the value.

How does the type narrowing work?

Result is a discriminated union keyed on the ok boolean. Checking if (result.ok) tells the compiler which branch you're in, so value is available on success and error on failure — and not the other way around.

Is there a library for this?

Yes — neverthrow and fp-ts provide richer Result/Either types with map, andThen, and combinators. The hand-rolled type here is enough for most codebases and has zero dependencies.

Should everything return Result?

No. Use it for expected, recoverable failures — parsing, validation, I/O. For unrecoverable programmer errors, a plain throw is clearer.

Related snippets

Next

Binary search in Python