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.
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
ifand composes into a chain, so the whole library can be built from it without a DSL or transpiler. - Because
matchesnarrows,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.
.withwidens the result typeRtoR | V; a value that changes type in place cannot be represented soundly. A new value per.withis 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
Tis used consistently afterwards.
Known issue
- The type parameter and the runtime name can disagree (
P.type<number>("string")compiles), because nothing tiesTtoname.P.whenwith 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:
Sdescribes the check, not the value. Arefinetype guard is the explicit point where the value is narrowed toT. - 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, soP.shapeon its own is easy to misuse. The README shows therefineform; preferP.whenwhen 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
everyover an array).P.anyis the escape hatch for those.
Known issue
- Like
P.type, the declaredTis not proven by the predicate. PreferP.whenwhenever the check can be written as a guard.