Files
initiative/docs/adr/001-errors-as-values.md
Lukas d653cfe489 Add ADR template and first two architecture decision records
Document the errors-as-values pattern (ADR-001) and domain events
as plain data objects (ADR-002) to capture the reasoning behind
these foundational design choices.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:19:54 +01:00

2.9 KiB

ADR-001: Errors as Values, Not Exceptions

Date: 2026-03-25 Status: accepted

Context

Domain functions need to communicate failure (invalid input, missing combatant, violated invariants). The standard JavaScript approach is to throw exceptions, but thrown exceptions are invisible to TypeScript's type system — nothing in a function's signature tells the caller that it can fail or what errors to expect.

This project's domain layer is designed to be pure and deterministic. Thrown exceptions break both properties: they alter control flow (a side effect) and make the function's output unpredictable from the caller's perspective.

Decision

All domain functions return SuccessType | DomainError unions. DomainError is a plain data object with a kind discriminant, a machine-readable code, and a human-readable message:

interface DomainError {
  readonly kind: "domain-error";
  readonly code: string;
  readonly message: string;
}

Callers check results with the isDomainError() type guard before accessing success data. Errors are never thrown in the domain layer (adapter-layer code may throw for programmer errors like missing providers).

Alternatives Considered

Thrown exceptions — the JavaScript default. Simpler to write (throw new Error(...)) but error paths are invisible to the type system. The caller has no compile-time indication that a function can fail, and catch blocks lose type information about which errors are possible. Would also make domain functions impure.

Result wrapper types (e.g., neverthrow, ts-results) — formalizes the pattern with .map(), .unwrap(), .match() methods. More ergonomic for chaining operations, but adds a library dependency and a layer of indirection. The project's use cases are simple enough (call domain function, check error, save or return) that raw unions are sufficient.

Validation libraries (Zod, io-ts) — useful for input parsing but don't cover domain logic errors like "combatant not found" or "no previous turn". Would only address a subset of the problem.

Consequences

Positive:

  • Error handling is compiler-enforced. Forgetting to check for an error produces a type error when accessing success fields.
  • Domain functions remain pure — they return data, never alter control flow.
  • Error codes are stable, machine-readable identifiers that UI code can match on.
  • Testing is straightforward: assert the return value, no try/catch in tests.

Negative:

  • Every call site must check isDomainError() before proceeding. This is slightly more verbose than a try/catch that wraps multiple calls.
  • Composing multiple fallible operations requires manual chaining (check error, then call next function). A Result wrapper would make this more ergonomic if the codebase grows significantly.
  • Contributors familiar with JavaScript conventions may initially find the pattern unfamiliar.