TypeScript Result Type Patterns
Quick Guide: A
Result<T, E>is a discriminated union onok, so TypeScript refuses to readvalueuntil the caller has checked. That moves a function's failure modes into its signature, where an exception hides them. Use it for expected failures — validation, parsing, requests — and keep exceptions for bugs and for conditions nothing downstream can act on. A custom implementation is about forty lines and the recommended default; the whole surface is in this skill.
Detailed Resources:
- examples/core.md — the Result module, typed error definitions, wrapping throwing code, pattern matching
- examples/async.md —
Promise<Result>, async chaining, retry, converting a promise - examples/combining.md — fail-fast, collect-all, object and sequential combination
- reference.md — operation lookup, what Results do not catch, error-type templates
Which path applies
- Nothing exists yet — write the module: the union,
ok,err,map,flatMap,match,tryCatch. examples/core.md is the whole file. - A library owns the type — the operations are named differently but compose identically; reference.md maps the names.
- The failing operation is async — the type is
Promise<Result<T, E>>and the awaiting is the caller's; see examples/async.md.
<critical_requirements>
Before writing Result code
Check result.ok before reading value or error. The union narrows only through that check, so TypeScript will refuse either access until it is made — and a runtime undefined is what a bypassed check produces.
Wrap every throwing call inside a Result-returning function in tryCatch. JSON.parse and its kin throw past the return type, so one unwrapped call makes the signature a lie and the caller's exhaustive handling incomplete.
Give each error a discriminant field — code or type — rather than typing it as Error or string. The discriminant is what lets the caller switch and lets TypeScript check the switch is exhaustive; a bare message can only be displayed.
Chain with flatMap where each step returns a Result. The error type unions itself and the first failure short-circuits the rest, which is what nested if (result.ok) blocks are reimplementing by hand.
Do something with every Result you receive. A discarded one is a failure that never happened as far as the rest of the program is concerned, and no type error marks it.
</critical_requirements>
Auto-detection: Result type, Either type, ok err, railway-oriented programming, error as value, flatMap andThen, tryCatch, unwrapOr, combineWithAllErrors, discriminated union error, typed errors
Applies to:
- Expected, recoverable failures — validation, parsing, requests, business rules
- Function signatures that have to name every way they can fail
- Chaining fallible steps so the first failure skips the rest
- Collecting every failure at once, as form validation needs
Handled elsewhere:
- Render-phase failures — a component that throws is caught by whatever wraps it, and a Result never reaches that path.
- Transport and caching — a Result describes the outcome of a request; issuing, retrying and caching it belong to whatever fetches.
- Schema validation — a validator that reports issues has its own result shape; wrap it at the boundary and carry its report as your error payload.
- Turning a failure into a response — the status code an error maps to is the API layer's rule, and this skill only guarantees the error arrives typed.
<philosophy>
Philosophy
An exception is invisible control flow: it leaves no trace in the type, so the only way to know a function throws is to read it or to be surprised in production. A Result puts the same information in the signature, where the compiler enforces it.
The cost is real — every caller handles or propagates, and the error union grows as a chain lengthens. That is why the boundary matters: convert throwing code to Results on the way in, and convert Results to whatever the outside world wants on the way out. In between, nothing throws.
The railway: success runs the main line, and the first error switches to the parallel one, where every later step is skipped until something explicitly handles it.
parseNumber validatePositive double
OK ─────────────────────────────────────────────> success
↘ ↘
ERR ────────────────────────────> failure
</philosophy>
<decision_framework>
Result, exception, or nullable
Can the caller do something about this failure?
├─ NO — it is a bug or a condition nothing can act on → throw
│ ├─ Index out of bounds, invalid internal state
│ └─ Missing startup configuration, unreachable database at boot
└─ YES → What does the failure need to carry?
├─ Nothing but its own absence → T | null
├─ A reason the caller branches on → Result<T, E>
└─ Several distinct reasons → Result<T, E> with a discriminated E
A Result<User, NotFoundError> whose error carries only code: "NOT_FOUND" is a nullable wearing a
costume. Reach for the Result when the caller's next action differs by reason.
Fail fast or collect everything: one invalid field in a form is not a reason to hide the other four, so form validation collects; a chain where step two consumes step one's output has nothing to collect and short-circuits.
Returning a value also costs far less than throwing one, because a thrown error captures a stack trace and unwinds; reference.md carries the measured comparison. That is a tiebreaker on a hot path rather than a reason on its own.
</decision_framework>
<patterns>
Core patterns
Pattern 1: The type and its constructors
ok as the discriminant, readonly throughout, never on the other side so inference stays clean.
export type Result<T, E = Error> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
Full code: examples/core.md
Pattern 2: map and mapError
Each transforms one side and passes the other through untouched, which is what makes them safe to apply to a Result you have not checked.
export const map = <T, U, E>(
result: Result<T, E>,
fn: (value: T) => U,
): Result<U, E> => (result.ok ? ok(fn(result.value)) : result);
export const mapError = <T, E, F>(
result: Result<T, E>,
fn: (error: E) => F,
): Result<T, F> => (result.ok ? result : err(fn(result.error)));
mapError is where context is added — the operation that failed, the input that caused it.
Pattern 3: flatMap for chaining
The step returns a Result of its own, so the error types union and the first failure ends the chain.
export const flatMap = <T, U, E, F>(
result: Result<T, E>,
fn: (value: T) => Result<U, F>,
): Result<U, E | F> => (result.ok ? fn(result.value) : result);
const parsed = flatMap(parseNumber(input), validatePositive);
// Result<number, ParseError | ValidationError>
Full code: examples/core.md
Pattern 4: tryCatch at the boundary
Throwing code is converted where it enters, and the error is mapped to this domain's type in the same call.
export const tryCatch = <T, E>(
fn: () => T,
onError: (error: unknown) => E,
): Result<T, E> => {
try {
return ok(fn());
} catch (error) {
return err(onError(error));
}
};
const parsed = tryCatch(
() => JSON.parse(json) as Config,
(error): ParseError => ({
code: "PARSE_ERROR",
message: String(error),
input: json,
}),
);
A JSON.parse left unwrapped inside a Result-returning function is the commonest way the signature
stops being true.
Full code: examples/core.md
Pattern 5: match for exhaustive handling
Both sides answered in one expression, which is what makes it the natural converter at an outbound boundary.
export const match = <T, E, U>(
result: Result<T, E>,
handlers: { ok: (value: T) => U; err: (error: E) => U },
): U => (result.ok ? handlers.ok(result.value) : handlers.err(result.error));
const response = match(loadUser(id), {
ok: (user) => ({ status: 200, body: user }),
err: (error) => toHttpResponse(error),
});
Full code: examples/core.md
Pattern 6: Discriminated error unions
Each variant carries what its own handler needs, and the union names every way the function fails.
type UserError =
| { readonly code: "NOT_FOUND"; readonly userId: string }
| {
readonly code: "VALIDATION_ERROR";
readonly field: string;
readonly message: string;
}
| { readonly code: "NETWORK_ERROR"; readonly statusCode: number };
if (!result.ok) {
switch (result.error.code) {
case "NOT_FOUND":
return showMissing(result.error.userId);
case "VALIDATION_ERROR":
return highlightField(result.error.field);
case "NETWORK_ERROR":
return offerRetry();
}
}
Adding a variant reddens every switch that does not handle it, which is the whole return on the discriminant.
Full code: examples/core.md
Pattern 7: Combining several Results
Fail-fast returns the first error; collect-all returns every one.
export const combine = <T, E>(results: Result<T, E>[]): Result<T[], E> => {
const values: T[] = [];
for (const result of results) {
if (!result.ok) return result;
values.push(result.value);
}
return ok(values);
};
Full code: examples/combining.md
</patterns><red_flags>
Red flags
Breaks at runtime:
- Reading
result.valuewithout checkingok—undefinedat the point of use, and a non-null assertion or a cast is what got it past the compiler. - A throwing call left unwrapped inside a Result-returning function — the exception escapes a caller who was told there was nothing to catch.
- Treating an error object as
instanceof Error— a plain discriminated object is not one, so aninstanceofcheck silently takes the wrong branch.
Surprising behaviour:
- Discarding a Result compiles cleanly. Nothing in the type system marks the failure you dropped.
mapwith a function that itself returns a Result givesResult<Result<T, F>, E>— it type-checks, and the caller has to unwrap twice to reach anything. That doubling is whatflatMapexists to prevent.flatMapunions error types, so a long chain ends with an error union nobody wants to handle — narrow it withmapErrorat the point the extra variants stop mattering.Result<void, E>rather thanResult<undefined, E>for an operation with no success value; the second forces callers to name a value that does not exist.- A
Promise<Result<T, E>>is truthy while it is pending, so an unawaited one passes anokcheck that means nothing. - Rethrowing at a boundary throws the typed error away — the reason the caller could have branched on becomes a string.
combineWithAllErrorsreturning an array whose first element is all anyone displays wastes the work; either show them all or fail fast.- A pre-created error constant saves an allocation and loses the context that would have gone in it.
</red_flags>
Scan to join WeChat group