7.6 KiB
Library design
The type-level design of the public API and the limitations it carries. The user-facing reference is README § API.
The matchers are implemented in src/primitive.ts (getMatcher /
getMatcherW) and src/tagged-union.ts (getTaggedUnionMatcher /
getTaggedUnionMatcherW), re-exported from src/index.ts; the rest of the
library is placeholder code.
Matcher shape
Decision (2026-09)
A factory takes the universe and returns a builder; the builder takes a handler map and an optional fallback:
const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
Exhaustive or fallback is decided at the call site, by whether the second
argument is present. The fallback's parameter is the remainder
Exclude<T, keyof Handled>. Only the return-strictness axis remains, so there
are two factories:
getMatcher— one commonR; the fallback must fit it;getMatcherW— the unionPatternReturns<Handled> | R.
Each factory is two overloads whose order is load-bearing:
Handlers<T, R>— the exhaustive form, and the contextual type of the handler-map popup;Handled extends Exact<Partial<Handlers<T, R>>, Handled>intersected withMustBePartial<T, Handled>, plusFallback<T, Handled, R>— a partial handler map plus the fallback, rejected when the map already coversT.
Why
- The fallback is an argument, not a property. TypeScript fixes a property's
contextual type before it infers its sibling keys, so
_: (s) => …in the handler map can only see all ofT, neverExclude<T, keyof Handled>. A later argument is contextually typed from inference on an earlier one, so the split is what makes the remainder expressible. - The redundant-fallback guard is an F-bounded constraint. A map that
already covers
Tplus a fallback is rejected by foldingMustBePartial<T, Handled>intoHandled's own constraint. The guard is checked afterHandledis inferred, so the contextual pass that types the handler callbacks survives. The obvious conditionalExclude<T, keyof Handled> extends never ? …in the fallback's parameter type is evaluated whileHandledis still its constraint and rejects every partial map whose callbacks are context-sensitive. - Overload order keeps both messages. #1 supplies the contextual type
(
a, b, c); #2 accepts a partial map once a fallback is present, so its popup is optional (a?, b?, c?). A gap without a fallback is reported against #1. Rneeds an inference site.Rinside theExact<…>constraint is not one, sohandlers: Handled & Partial<Handlers<T, R>>re-adds it; without thatRcollapses tounknownwhen the handler params are inferred.Exactrestores the excess-property check. TypeScript skips it for a generic constraint, so withoutExactthe handler map accepts keys outsideT.- Two factories, not four: the fallback is an argument, not a separate API.
Rejected
- Single-object
_(the former shape)._sees only all ofT; the remainder is not expressible there, and an exhaustive map plus_was accepted. - Curried handlers-first —
(handlers)(fallback). Rejected: two calls for the common case. It is not needed for the redundant-fallback guard, which the F-bounded constraint already provides (see Why). this/ HKT self-reference.thisis post-construction (method bodies, return positions); a parameter's contextual type is pre-construction.keyof thisin an interface method is the interface, not the literal.- Variance /
consttype parameters /NoInfer/unique symbolbrands / defaulted type-param guards. None change inference or evaluation order;in/outon the handler map broke contextual typing outright.NoInferspecifically leaks into the emitted.d.ts, raising the consumer floor to TypeScript 5.4 (README promises>= 5.0). - Union merge, overload merge with only the exhaustive arm last,
inferred universe, conditional
RequireKeys, cases-first curried — decided against while the API was single-object; their reasons (reported near-miss member, no_in the exhaustive popup,NoInfer/floor,keyof Pcounts optional keys, not pipe-friendly) hold where they still apply.
Known issue
PatternReturnsmust beReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>>so it survives the closed, partly-optionalPconstraints.Parameters<typeof factory>[0]resolves only the last overload, so it is not a sound "rejected" oracle for a factory. Factory-negative tests use@ts-expect-errorcall sites (the test file only — the general ban stands).
Tagged-union matcher
Decision (2026-09)
getTaggedUnionMatcher / getTaggedUnionMatcherW mirror the primitive pair
with one extra curried step for the discriminant key:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
const area = getTaggedUnionMatcher<Shape>()("kind")({
circle: (s) => Math.PI * s.radius ** 2,
square: (s) => s.side ** 2,
});
const fallback = getTaggedUnionMatcher<Shape>()("kind")(
{ circle: (s) => … },
(s) => …, // s: { kind: "square"; side: number }
);
The key is a separate call because K is inferred from its literal argument and
T is fixed by the first factory; one call could not infer both.
Discriminated<T> restricts the key to properties whose values are tags.
Why
- Same fallback/remainder machinery as the primitive matcher.
HandledMembersmaps the handled tags to their members andExclude<T, …>is the fallback's parameter; the redundant-fallback guard is the same F-bounded constraint. Only the "universe" changes —T's members instead of primitive values. T extends object, notRecord<PropertyKey, unknown>. Aninterfacehas no implicit index signature, so theRecordconstraint would reject interface-based unions. The runtime reads the tag offobjectwith one assertion, the tagged twin of the primitive dispatch'sshape as string | number.MapTaggedUniondistributes withExtract. A duplicated tag yields a union of members instead of dropping one.
Known issue
- Tags are
string | numberonly. Aboolean/null/undefineddiscriminant ({ ok: true } | { ok: false }) is rejected byDiscriminated, because those values are not property keys; supporting them needs the primitive matcher'sPatternKey/PatternParamprojection. - A member's tag must be unique across the union; two members with the same tag collapse to a union under one handler.
Primitive universe
Decision (2026-09)
The universe (Matchable) is string | number | boolean | null | undefined,
with boolean admitted as true | false.
boolean/null/undefined are not property keys, so handler-map keys are a
projection (PatternKey: each member stringified) and PatternParam inverts
it, so callbacks receive the real member (true, not "true"). The popup
offers true, false, null, undefined by name (verified over LSP).
Why
- Runtime dispatch indexes with the raw
shape;handlers[true]coerces to"true"at runtime exactly asStringwould. Theshape as string | numberassertion only placatesTS2538and buys the number fast path (an explicitString()defeats V8's numeric-key path: measured ~2× on number-keyed dispatch). symbol/bigint/NaN/-0are rejected, and a member colliding with its stringification is unguarded: user-facing, stated once in README § Caveats.