web-error-handling-result-types · git:20260906.3dc53ce · 2026-09-06 · sha256 706bc29789e39908
web-error-handling-result-types git:20260906.3dc53ceA
Immutable. This exact content is served forever at /api/v1/blob/706bc29789e39908.
---
name: web-error-handling-result-types
description: TypeScript Result/Either types for type-safe error handling, railway-oriented programming patterns, error as values
---
# TypeScript Result Type Patterns
> **Quick Guide:** A `Result<T, E>` is a discriminated union on `ok`, so TypeScript refuses to read `value` until 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](examples/core.md) — the Result module, typed error definitions, wrapping throwing code, pattern matching
- [examples/async.md](examples/async.md) — `Promise<Result>`, async chaining, retry, converting a promise
- [examples/combining.md](examples/combining.md) — fail-fast, collect-all, object and sequential combination
- [reference.md](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](examples/core.md) is the whole file.
- **A library owns the type** — the operations are named differently but compose identically;
[reference.md](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](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](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.
```typescript
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](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.
```typescript
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.
```typescript
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](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.
```typescript
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](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.
```typescript
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](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.
```typescript
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](examples/core.md)
### Pattern 7: Combining several Results
Fail-fast returns the first error; collect-all returns every one.
```typescript
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](examples/combining.md)
</patterns>
---
<red_flags>
## Red flags
**Breaks at runtime:**
- Reading `result.value` without checking `ok` — `undefined` at 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 an `instanceof` check silently takes the wrong branch.
**Surprising behaviour:**
- Discarding a Result compiles cleanly. Nothing in the type system marks the failure you dropped.
- `map` with a function that itself returns a Result gives `Result<Result<T, F>, E>` — it type-checks, and the caller has to unwrap twice to reach anything. That doubling is what `flatMap` exists to prevent.
- `flatMap` unions error types, so a long chain ends with an error union nobody wants to handle — narrow it with `mapError` at the point the extra variants stop mattering.
- `Result<void, E>` rather than `Result<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 an `ok` check that means nothing.
- Rethrowing at a boundary throws the typed error away — the reason the caller could have branched on becomes a string.
- `combineWithAllErrors` returning 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>