Real-world modelling: errors, API and state
Error and result types, API envelopes, loading state as a union, branded ids and state machines that make invalid transitions unwritable.
Error and result types
// errors as values: the caller must look before using anything
type Result<T, E = AppError> =
| { ok: true; value: T }
| { ok: false; error: E };
// a closed set of failures, each carrying exactly what a handler needs
type AppError =
| { kind: 'not_found'; id: string }
| { kind: 'validation'; fields: Record<string, string> }
| { kind: 'unavailable'; retryAfterSeconds: number };
function message(error: AppError): string {
switch (error.kind) {
case 'not_found':
return 'No record ' + error.id;
case 'validation':
return Object.keys(error.fields).length + ' field(s) are invalid';
case 'unavailable':
return 'Try again in ' + error.retryAfterSeconds + 's';
}
}
// an Error subclass keeps the stack trace; a bare object literal does not
class ApiError extends Error {
constructor(readonly status: number, readonly body: unknown) {
super('HTTP ' + status);
this.name = 'ApiError';
}
}
function unwrap<T>(result: Result<T>): T {
if (result.ok) return result.value;
throw new ApiError(422, result.error);
}Pick one convention per layer and hold it. Exceptions are for the unrecoverable and the programmer's mistake; a result type is for failures the caller is expected to handle, such as validation, a conflict or a missing record. Mixing the two at random means every caller has to guess.
API envelopes and loading state
// one envelope for every endpoint: the client has one shape to handle
interface Ok<T> { data: T; error: null }
interface Err { data: null; error: { code: string; message: string; details?: unknown } }
type Envelope<T> = Ok<T> | Err;
function isOk<T>(envelope: Envelope<T>): envelope is Ok<T> {
return envelope.error === null;
}
// UI state is a union too, so impossible combinations cannot be written
type LoadState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; value: T }
| { status: 'error'; message: string };
function render(state: LoadState<User[]>): string {
switch (state.status) {
case 'idle':
return 'Nothing loaded yet';
case 'loading':
return 'Loading...';
case 'ready':
return state.value.length + ' users';
case 'error':
return state.message;
}
}
// no flags to keep in sync: a value is never both loading and ready| Approach | Prevents | Cost |
|---|---|---|
| String status field | Nothing - any string compiles | None |
| Discriminated union | Invalid states, unhandled cases | Slightly more verbose to construct |
| Result type | Forgetting to handle failure | Callers must unwrap before use |
| Error subclass | Losing stack traces and causes | One class per error family |
| Branded type | Mixing up two string ids | A cast at the boundary |
| Boolean flags | Ambiguous combinations | Grows as two to the power of n |
Branded ids and state machines
// branded types stop two string ids being passed in the wrong order
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
const asUserId = (value: string): UserId => value as UserId;
const asOrderId = (value: string): OrderId => value as OrderId;
function loadUser(id: UserId): Promise<unknown> { /* ... */ }
// loadUser('u_1'); // error: a plain string is not a UserId
loadUser(asUserId('u_1'));
// a state machine as a union: every transition is a function on one state
interface Line { sku: string; quantity: number }
type Order =
| { state: 'draft'; lines: Line[] }
| { state: 'placed'; lines: Line[]; placedAt: Date }
| { state: 'shipped'; lines: Line[]; tracking: string }
| { state: 'cancelled'; lines: Line[]; reason: string };
function ship(order: Order, tracking: string): Order {
if (order.state !== 'placed') {
throw new Error('only a placed order can ship');
}
return { state: 'shipped', lines: order.lines, tracking };
}
// placing twice cannot happen: draft and placed are different types,
// and each accepts only its own transition⚠️
One
any in a public signature disables checking for every caller downstream, and it spreads silently through inference. Keep any out of exported APIs: use unknown plus a parse, or a generic that keeps the caller's type.FAQ
Exceptions or result types?
Exceptions for the unrecoverable and for programmer mistakes; result types for failures the caller is expected to handle, such as validation, not-found or a conflict, because the type then forces every call site to deal with them.
Are branded types safe?
They are a compile-time label only: at runtime the value is still a plain string, so a cast can produce a branded value that was never checked. Always construct them through the helper that validates the input.
Related
Narrowing, unions and exhaustive checks Runtime validation at the edges
Last refreshed 2026-09-18.