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>
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.