Typed errors and the Result pattern
The async-error-handling lesson introduced the Result pattern for asynchronous failures. This lesson
makes it a general principle for all error handling, and settles the question every TypeScript developer
faces: when should a failure be a thrown exception, and when should it be a value the type system can
see? Getting this boundary right is a mark of engineering maturity.
The problem with exceptions: the type system cannot see them
Exceptions (throw/catch) are JavaScript's built-in error mechanism, and they have a fundamental
limitation for TypeScript: a function's type does not reveal that it can throw.
function parseAge(input: string): number {
const n = Number(input);
if (Number.isNaN(n)) {
throw new Error(`"${input}" is not a number`);
}
return n;
}
const age = parseAge(userInput); // the type says `number` — no hint it can throw
parseAge's type is (input: string) => number. Nothing in that signature tells a caller "this might
throw." So a caller has no compile-time nudge to handle the failure — they can call parseAge and use
the result, and if it throws, they find out at runtime (an uncaught exception crashing the program). The
failure is invisible to the type system. This is fine for exceptional failures (a bug, a broken
invariant) that should propagate and crash. It is a poor fit for expected failures (bad input,
not-found) that are a normal part of the operation and that callers should handle.
The Result pattern: failure as a value in the type
Make the failure part of the return type with a discriminated union (the model-with-types principle, applied to errors):
type Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseAge(input: string): Result<number> {
const n = Number(input);
if (Number.isNaN(n)) {
return { ok: false, error: `"${input}" is not a number` };
}
return { ok: true, value: n };
}
const result = parseAge(userInput);
if (result.ok) {
console.log(result.value + 1); // narrowed — value available
} else {
console.log(result.error); // narrowed — error available; the caller MUST handle this branch
}
Now parseAge's type is (input: string) => Result<number> — the possibility of failure is right there
in the signature. A caller cannot get to result.value without first checking result.ok, so the
compiler forces them to handle the failure. The error is a value that flows through your program, visible
and type-checked, not a hidden throw a caller might forget to catch. This is the async lesson's pattern
generalised: model expected failure as a Result value.
A richer error type
The error in a Result need not be a string — for a domain with specific failure kinds, make it a
discriminated union too, giving exhaustive, typed error handling:
type ParseError =
| { kind: "empty" }
| { kind: "not-a-number"; input: string }
| { kind: "out-of-range"; value: number; max: number };
function parseAge(input: string): Result<number, ParseError> {
if (input.trim() === "") return { ok: false, error: { kind: "empty" } };
const n = Number(input);
if (Number.isNaN(n)) return { ok: false, error: { kind: "not-a-number", input } };
if (n > 150) return { ok: false, error: { kind: "out-of-range", value: n, max: 150 } };
return { ok: true, value: n };
}
const result = parseAge(userInput);
if (!result.ok) {
switch (result.error.kind) { // exhaustive handling of each failure kind
case "empty": return "Please enter an age";
case "not-a-number": return `"${result.error.input}" is not a number`;
case "out-of-range": return `Max is ${result.error.max}`;
}
}
Now each failure carries exactly its relevant data (input, value, max), and a switch over
error.kind can be made exhaustive (the never pattern) — so adding a new failure kind forces you to
handle it everywhere. This is the strongest form: failures are typed, specific, and impossible to
ignore. It is more work than throwing a string, and worth it exactly when the different failures need
different handling.
When to throw, and when to return a Result
The decision, which is the heart of good error design:
-
throwan exception for exceptional, unexpected failures — a bug, a violated precondition, a genuinely broken state, an error you cannot sensibly recover from at this level. These should propagate up and crash (or be caught at a top-level boundary that logs and reports). Usethrow(with a realError) when "this should not happen" or "there is nothing useful to do here." -
Return a
Resultfor expected failures that are part of normal operation — validation failing, a resource not found, a payment declined, parsing bad input. These are normal outcomes the caller should handle, and putting them in the type ensures they do. UseResultwhen the failure is a legitimate result, not an emergency.
The test: is this failure a normal, expected outcome the caller should decide about (→ Result), or an
exceptional situation there is no sensible local handling for (→ throw)? A not-found user is expected
(Result); a database connection dying mid-query is exceptional (throw). Most robust codebases use both,
deliberately — and choosing correctly per case is the skill.
A note on libraries and ergonomics
TypeScript does not have a built-in Result type (unlike Rust's Result or Kotlin's), so teams either
define their own (as above) or use a library — neverthrow is a popular one, adding methods like
.map and .andThen for chaining Results ergonomically. And validation libraries embrace this pattern:
Zod's .safeParse() returns a Result-like { success: true; data } or { success: false; error }
instead of throwing — precisely so the failure is a value you handle. When you see safeParse, you are
seeing the Result pattern in a real library, and now you know why it is shaped that way.
Check your work
The limitation of exceptions for TypeScript. A function's type does not reveal it can throw, so callers get no compile-time nudge to handle the failure.
The Result pattern. Model failure as a value in the return type — a discriminated union { ok: true; value } | { ok: false; error } — so the compiler forces callers to handle both branches.
Why a Result beats a throw for expected failures. The failure is visible in the signature and
type-checked; a caller cannot reach the value without handling the error.
A richer error type. Make error a discriminated union of failure kinds, each carrying its data, for
exhaustive typed handling.
When to throw. For exceptional, unexpected failures — bugs, broken invariants, unrecoverable errors —
that should propagate.
When to return a Result. For expected failures that are normal outcomes the caller should handle —
validation, not-found, declined.
The deciding test. Is the failure a normal expected outcome (→ Result) or an exceptional situation with no sensible local handling (→ throw)?
Libraries. No built-in Result; teams define their own or use neverthrow; Zod's safeParse returns a
Result-like value.
Practice
- Write
parseAgethatthrows, and note its type gives no hint it can throw. Call it without handling the throw and crash it. - Rewrite it to return
Result<number>and confirm the caller must checkokbefore usingvalue. - Make the error a discriminated union (
ParseError) with three kinds carrying data, and handle each exhaustively. - Add a fourth error kind and watch the exhaustive
switchforce you to handle it. - For five failures (a not-found, a validation error, a null-pointer bug, a network timeout, a declined
payment), decide
throworResultfor each and justify. - Look up Zod's
safeParsereturn type and identify the Result pattern in it. - Take a function in code you know that throws on an expected failure, and convert it to return a
Result.
Official documentation
- TypeScript — Discriminated Unions — The basis of the Result type.
- TypeScript — Handling errors (narrowing caught errors) — For the throw side.
- neverthrow — A Result library for TypeScript.
- Zod — safeParse — The Result pattern in a validation library.
Next: reading TypeScript like a reviewer.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship