Files
tiny-pattern-ts/development/library.md
T
tmu 74c39e1346 ♻️ Tighten the development/ prose
Same decisions, rationale, rejected alternatives and known issues, said with
less padding: ~5,530 -> ~4,730 words (-15%). Every fact from the first draft is
kept; only the wording, duplicated lead-ins and restated context are cut.
2026-09-15 13:56:49 +00:00

3.3 KiB

Library design

The type-level design of the public API and the limitations it carries. The user-facing reference is README § API.

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.

Why

  • A type guard is the one TypeScript construct that both narrows in an if and composes into a chain, so the library needs no DSL and no transpiler.
  • Because matches narrows, match(value).with(pattern, handler) types the handler with no cast and no runtime tag.
  • Pattern<T> is an alias, so either name is the same type.

The builder is immutable

Decision (2026-09)

.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 to R | V, which cannot be represented by a value that changes type in place.

exhaustive() is a runtime check

Decision (2026-09)

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

Why

  • Proving coverage through a fluent chain would need a compiler plugin or a builder that tracks an uncovered-member set — out of proportion to this library's size.
  • The union of handler return types is still type-safe; only the chain's completeness is unchecked.

Known issue

  • A missing case is a runtime Error, not a compile error. A caller wants either an .otherwise(...) (which never throws) or to prove coverage themselves. Tracking uncovered members is a possible future change.

P.type takes the type as a parameter

Decision (2026-09)

P.type<T>(name) requires the caller to supply T; 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

  • T and the runtime name can disagree (P.type<number>("string") compiles), because nothing ties them together. Prefer P.when with a real type guard. A name-to-type conditional mapping is a possible future change.

P.shape narrows only through refine

Decision (2026-09)

P.shape<S, T>(shape, refine?) returns Matcher<T>, defaulting to Matcher<S> without a refine.

Why

  • The shape's values may be matchers, so S describes the check, not the value; the refine guard is the explicit point where the value narrows to T.
  • Keys are checked with in rather than requiring an exact match, so a shape can 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 alone is easy to misuse. Prefer P.when when a guard is already available.

P.any exists for non-guard predicates

Decision (2026-09)

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

Why

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

Known issue

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