Files
tiny-pattern-ts/development/library.md
T
tmu dc7ce22fc5 📝 Add development/ docs for decisions and known issues
Category files under development/ replace the decision prose that was
scattered through README.md and CONTRIBUTING.md. Each decision is a block with
Decision (YYYY-MM) / Why / Rejected / Known issue; the new library.md records
the public API contract and its limitations. AGENTS.md and backlog.tasks now
point at the new home.
2026-09-15 13:26:59 +00:00

4.0 KiB

Library design

The type-level design of the public API, and the limitations it carries. The user-facing reference is README § API; this file records why the API is shaped the way it is.

A type guard is the single primitive

Decision (2026-09)

Every pattern constructor returns a Matcher<T> whose only member is matches: (value: unknown) => value is T. Pattern<T> is an alias for Matcher<T>.

Why

  • A type guard is the one TypeScript construct that both narrows in an if and composes into a chain, so the whole library can be built from it without a DSL or transpiler.
  • Because matches narrows, match(value).with(pattern, handler) can give the handler the right type with no cast and no runtime type tag.
  • Keeping Pattern<T> as an alias means a caller can name either; the type is identical.

The builder is immutable

Decision (2026-09)

match(value) returns a builder whose .with returns a new builder rather than mutating the current one.

Why

  • A partially built chain can be stored and reused without one call site affecting another.
  • .with widens the result type R to R | V; a value that changes type in place cannot be represented soundly. A new value per .with is what makes the type-level accumulation work.

exhaustive() is a runtime check

Decision (2026-09)

.exhaustive() throws at runtime when no case matched. It does not statically prove that every member of the input union has a case.

Why

  • TypeScript cannot require a fluent call chain to cover every union member without a compiler plugin or a builder whose type tracks an uncovered-member set. That machinery is out of proportion to the library's size.
  • The union-of-return-types (R) is still type-safe; what is not guaranteed is that the chain is complete.

Known issue

  • A missing case is a runtime Error, not a compile error. A caller who wants compile-time safety must either add a .otherwise(...) (which never throws) or prove coverage themselves. Making the builder track uncovered members is a possible future change; it is not in scope now.

P.type takes the type as a parameter

Decision (2026-09)

P.type<T>(name) requires the caller to supply T explicitly; it is not inferred from the typeof string.

Why

  • A runtime string cannot carry a TypeScript type. The caller pairs the two, and the compiler only checks that T is used consistently afterwards.

Known issue

  • The type parameter and the runtime name can disagree (P.type<number>("string") compiles), because nothing ties T to name. P.when with a real type guard avoids the pairing entirely, so prefer it. A future revision could map the name to a type with a conditional type; that is not in scope now.

P.shape narrows only through refine

Decision (2026-09)

P.shape<S, T>(shape, refine?) returns Matcher<T>, defaulting to Matcher<S> when no refine is given.

Why

  • The shape object's values may be matchers, so the shape's literal type is not the matched type: S describes the check, not the value. A refine type guard is the explicit point where the value is narrowed to T.
  • Checking keys with in (rather than requiring exact keys) lets a shape match a wider object, which is how discriminated unions are handled.

Known issue

  • Without refine, a discriminated-union case does not narrow, so P.shape on its own is easy to misuse. The README shows the refine form; prefer P.when when a guard is already available.

P.any exists for non-guard predicates

Decision (2026-09)

P.any<T>(predicate) accepts a boolean predicate and a declared T.

Why

  • Some checks are cheap to write as a boolean but awkward as a type guard (for example an every over an array). P.any is the escape hatch for those.

Known issue

  • Like P.type, the declared T is not proven by the predicate. Prefer P.when whenever the check can be written as a guard.