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.
3.3 KiB
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
ifand composes into a chain, so the library needs no DSL and no transpiler. - Because
matchesnarrows,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.
.withwidens the result type toR | 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
Tis used consistently afterwards.
Known issue
Tand the runtimenamecan disagree (P.type<number>("string")compiles), because nothing ties them together. PreferP.whenwith 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
Sdescribes the check, not the value; therefineguard is the explicit point where the value narrows toT. - Keys are checked with
inrather than requiring an exact match, so a shape can match a wider object — which is how discriminated unions are handled.
Known issue
- Without
refinea discriminated-union case does not narrow, soP.shapealone is easy to misuse. PreferP.whenwhen 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
everyover an array);P.anyis the escape hatch.
Known issue
- As with
P.type, the declaredTis not proven by the predicate. PreferP.whenwhenever the check can be written as a guard.