9.4 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-union.ts (getPrimitiveUnionMatcher /
getPrimitiveUnionMatcherW) 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 = getPrimitiveUnionMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getPrimitiveUnionMatcher<"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:
getPrimitiveUnionMatcher— one commonR; the fallback must fit it;getPrimitiveUnionMatcherW— 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).
Shared internals
Decision (2026-09)
src/matcher-shared.ts holds the seven universe-agnostic pieces both matchers
use: UnaryFn, PatternReturns, RedundantFallback, HandlerMap, the shared
Matchable universe, and the PatternKey / PatternParam key projection.
Why
RedundantFallback's property name is the diagnostic, so one definition keeps the two matchers' message from drifting; the other pieces appear verbatim in both public signatures or are the same projection over each matcher's universe.Matchableis one definition, not two. The primitive-union matcher's universe and the tagged-union matcher's allowedTagvalues are the same set, so aliasing them keeps the two matchers from drifting apart on what they accept (symbol/bigintrejected once).
Rejected
- A generic
Matcher<Universe>over the interface pair,Handlers,FallbackandMustBePartial. Each is built from its own universe (Tags/MapTaggedUnionvs the primitive values); abstracting over the F-boundedHandledconstraint that makes the remainder work risks the contextual typing it exists to preserve.Matchableand thePatternKey/PatternParamprojection are the pieces both universes genuinely share.
Tagged-union matcher
Decision (2026-09)
getTaggedUnionMatcher / getTaggedUnionMatcherW mirror the primitive-union 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-union 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-union dispatch'sshape as string | number.MapTaggedUniondistributes withExtract. A duplicated tag yields a union of members instead of dropping one.- A
boolean/null/undefinedtag goes through the sharedPatternKey/PatternParamprojection.Discriminatedadmits those tags (they are inTag), but they cannot key a mapped type, so the handler map is keyed by the stringified form (true→"true") andPatternParaminverts it to recover the member. This is the same projection the primitive-union matcher uses over its universe, which is why it lives inmatcher-shared.ts.
Known issue
- A member's tag must be unique across the union; two members with the same tag
collapse to a union under one handler. The same holds for a tag colliding with
its stringification (
true | "true") — see README § Caveats.
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). The
same projection is shared with the tagged-union matcher; see
§ Tagged-union matcher.
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.