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
ApproachPreventsCost
String status fieldNothing - any string compilesNone
Discriminated unionInvalid states, unhandled casesSlightly more verbose to construct
Result typeForgetting to handle failureCallers must unwrap before use
Error subclassLosing stack traces and causesOne class per error family
Branded typeMixing up two string idsA cast at the boundary
Boolean flagsAmbiguous combinationsGrows 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.

Narrowing, unions and exhaustive checks Runtime validation at the edges

Last refreshed 2026-09-18.