Add a `compat` job that type-checks the whole suite and a consumer fixture against the minimum supported TypeScript (5.9), reusing the `dist/` artifact `build` produced and gating `publish`. The compiler is resolved by npx, so it never enters `devDependencies` or the local `check`/`verify` loop. The fixture imports the package by name, resolving the emitted declarations through the `exports` map; `expectTypeOf` / `.not.toBeAny()` make it reject an `any`-typed declaration, which a bare compile would accept. Correct the README consumer floor from >= 5.0 to >= 5.9 (set by `type-fest`) and drop the `node10` resolution claim, which the exports-only entry never satisfied.
15 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 src/
is implementation detail.
Public surface
Decision (2026-09)
src/index.ts exports the four factories and nothing else. Every exported
function carries TSDoc; the builder types and the matcher-shared.ts
vocabulary stay internal.
Why
- The factories are the whole contract: a consumer calls one and never needs to name the builder type it returns.
- The builder interfaces are already the inferred return types, so they travel
into the emitted
.d.tsregardless. Exporting them would only make them nameable while freezing the internalStrict/Wideningoverload split as API. - TSDoc travels into the emitted declarations, so editor hovers and the
published package document the API without a hand-written
.d.ts.
Rejected
- Exporting the builder types (
PrimitiveUnionMatcher, …). Nameable, but it grows the surface for no call-site benefit and pins theStrict/Wideningsplit. - Exporting the
matcher-shared.tsvocabulary (Matchable,UnaryFn,PatternKey,Member,PatternReturns, …). They appear in the public signatures, but a consumer never needs to name them; exporting them would freeze plumbing as API. - A hand-written
.d.tsor a separate API document. It would drift from the implementation; TSDoc is generated from the source.
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, which would raise the consumer floor above the documented one (see README § Requirements). - 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 universe-agnostic pieces both matchers use:
UnaryFn, PatternReturns, RedundantFallback, HandlerMap, the shared
Matchable universe, the PatternKey key projection and its Member inverse,
and the Stringified / Collisions / UnsupportedReason / UnsupportedUniverse
/ UniverseGate universe gate.
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.Matchable, thePatternKey/Memberprojection and theUniverseGateare the pieces both universes genuinely share.
Supported universes
Decision (2026-09)
A universe must be a finite union of literals with no
value/stringification collision. Broad types (string, number, a template
literal) and "true" | true / 1 | "1" are rejected; the factory intersects
UniverseGate<T> (the UnsupportedUniverse<Reason> diagnostic) into the
handler and fallback parameters. Member<T, K> replaces the PatternParam<K>
inversion: the handler parameter is the member(s) of T whose PatternKey is
K, so a standalone "true" is "true", not true.
Why
PatternKeyis not injective."true"andtrue(and1/"1") share a runtime key, soPatternParam<K>cannot recover the member.Member<T, K>inverts againstT, which is exact.- Broad types cannot be proven exhaustive. An index-like map lets a partial
object satisfy the exhaustive overload and reaches the
dispatchthrow. Rejecting at the boundary avoids threading an open/closed branch throughHandlers,FallbackandMustBePartial. - The finite-literal predicate is
IsLiteral<PatternKey<T>> extends true.IsLiteralisbooleanfor a union that mixes a literal with a broad type ("a" | \x-${number}`), soextends falsewould treat the mix as supported;extends true` is the check that rejects it. - Collisions are rejected, not merged.
Member<T, K>would be sound (the handler gets the union), but the API is one handler per member; rejecting keepsMembera singleton and the remainder exact. - The collision predicate is type-checkable.
Collisions<T> = Extract<T, Stringified<T>>catches numeric collisions too. - The gate is an intersection, not a branch, so
Rinference and the popup survive; a conditional parameter type would not.
Rejected
- Open universes with a required fallback (
fix/open-universe-*): sound, but left the collision hole and added anIsLiteral/OpenUniverseNeedsFallbackbranch through every handler type. Findings, kept so they are not re-run:{}satisfies an index signature (andExactmisses it); an index signature dominates contextual typing;Rinfers only from a non-self-referential parameter type ({ [K in keyof H]: … R … }givesunknown); an F-bounded guard referencingkeyof HandledinHandled's own constraint sees the constraint, not the map; all handlers share oneR(only the fallback widens it);IsLiteralis the finite-literal predicate. The user-facing consequence — open universes are a parsing or registry concern, not a dispatch one — is guidance in README § Why open universes are rejected. Member<T, K>without the gate: sound, but a colliding handler gets a union and1 | "1"stays one runtime key.- A round-trip injectivity gate (
IsEqual<T, PatternParam<PatternKey<T>>>): over-rejects standalone"true"/"false"/"null"/"undefined". - A case-list / ts-pattern builder: removes the collision class but drops the object map (footprint, popup) and reimplements an existing library.
- Normalize numeric keys to strings: makes
PatternKeyinjective but changes "numeric keys stay numbers" and defeats the numeric dispatch fast path.
Known issue
- A multi-collision universe lists every collision in the diagnostic.
- The
dispatchthrow is unreachable through the typed API; the throw tests widen the factory toFunctionto reach it.
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.
HandledTagsrecovers the tag values the map handled (Member<Tags, keyof Handled>) andNarrowed<T, K, Exclude<Tags, …>>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.Narroweddistributes overT, narrowingKto the tag. A member whoseKcannot take the tag drops out; a duplicated tag yields a union of members instead of dropping one.T[K] extends Vreturns the exact member (a discriminated union's declared interface) untouched, so the mapped form only handles a property that is itself a union.- A union-valued or optional discriminant is supported. A single shape whose
property is a union (
{ color: "red" | "green" | "blue" }) is narrowed per handler instead of being passednever. Matching a defined tag on an optional property ({ type?: "x" }) proves the key is present, so it becomes required ({ type: "x" }); theundefinedtag narrows it to{ type?: never }underexactOptionalPropertyTypes(absence) or{ type?: undefined }when the property explicitly admitsundefined. - A
boolean/null/undefinedtag goes through the sharedPatternKey/Memberprojection.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") andMemberinverts it against the tag set 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 tag need not be unique across the union. Two members sharing one is not a
soundness hole: they select a single runtime key, so one handler receiving
their union is the only correct behavior — the key is simply not a
discriminant. The gate rejects only the distinct-value collision
(
true | "true"), where two values share a key andMembercan no longer invert it; see § Supported universes. Pinned by the duplicate-tag tests insrc/tagged-union.test.ts.
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 Member inverts it
against the universe, so callbacks receive the real member (true, not
"true"; the standalone string "true" stays "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.
The supported universes are constrained as described in § Supported universes.
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 rejected by the gate: user-facing, stated once in README § Caveats.