Files
tmu 75807c4bd8 👷 Type-check the TS floor in CI
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.
2026-09-29 20:51:41 +00:00

15 KiB
Raw Permalink Blame History

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.ts regardless. Exporting them would only make them nameable while freezing the internal Strict / Widening overload 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 the Strict / Widening split.
  • Exporting the matcher-shared.ts vocabulary (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.ts or 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 common R; the fallback must fit it;
  • getPrimitiveUnionMatcherW — the union PatternReturns<Handled> | R.

Each factory is two overloads whose order is load-bearing:

  1. Handlers<T, R> — the exhaustive form, and the contextual type of the handler-map popup;
  2. Handled extends Exact<Partial<Handlers<T, R>>, Handled> intersected with MustBePartial<T, Handled>, plus Fallback<T, Handled, R> — a partial handler map plus the fallback, rejected when the map already covers T.

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 of T, never Exclude<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 T plus a fallback is rejected by folding MustBePartial<T, Handled> into Handled's own constraint. The guard is checked after Handled is inferred, so the contextual pass that types the handler callbacks survives. The obvious conditional Exclude<T, keyof Handled> extends never ? … in the fallback's parameter type is evaluated while Handled is 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.
  • R needs an inference site. R inside the Exact<…> constraint is not one, so handlers: Handled & Partial<Handlers<T, R>> re-adds it; without that R collapses to unknown when the handler params are inferred.
  • Exact restores the excess-property check. TypeScript skips it for a generic constraint, so without Exact the handler map accepts keys outside T.
  • Two factories, not four: the fallback is an argument, not a separate API.

Rejected

  • Single-object _ (the former shape). _ sees only all of T; 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. this is post-construction (method bodies, return positions); a parameter's contextual type is pre-construction. keyof this in an interface method is the interface, not the literal.
  • Variance / const type parameters / NoInfer / unique symbol brands / defaulted type-param guards. None change inference or evaluation order; in/out on the handler map broke contextual typing outright. NoInfer specifically 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 P counts optional keys, not pipe-friendly) hold where they still apply.

Known issue

  • PatternReturns must be ReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>> so it survives the closed, partly-optional P constraints.
  • 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-error call 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.
  • Matchable is one definition, not two. The primitive-union matcher's universe and the tagged-union matcher's allowed Tag values are the same set, so aliasing them keeps the two matchers from drifting apart on what they accept (symbol/bigint rejected once).

Rejected

  • A generic Matcher<Universe> over the interface pair, Handlers, Fallback and MustBePartial. Each is built from its own universe (Tags/MapTaggedUnion vs the primitive values); abstracting over the F-bounded Handled constraint that makes the remainder work risks the contextual typing it exists to preserve. Matchable, the PatternKey / Member projection and the UniverseGate are 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

  • PatternKey is not injective. "true" and true (and 1 / "1") share a runtime key, so PatternParam<K> cannot recover the member. Member<T, K> inverts against T, which is exact.
  • Broad types cannot be proven exhaustive. An index-like map lets a partial object satisfy the exhaustive overload and reaches the dispatch throw. Rejecting at the boundary avoids threading an open/closed branch through Handlers, Fallback and MustBePartial.
  • The finite-literal predicate is IsLiteral<PatternKey<T>> extends true. IsLiteral is boolean for a union that mixes a literal with a broad type ("a" | \x-${number}`), so extends 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 keeps Member a 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 R inference 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 an IsLiteral / OpenUniverseNeedsFallback branch through every handler type. Findings, kept so they are not re-run: {} satisfies an index signature (and Exact misses it); an index signature dominates contextual typing; R infers only from a non-self-referential parameter type ({ [K in keyof H]: … R … } gives unknown); an F-bounded guard referencing keyof Handled in Handled's own constraint sees the constraint, not the map; all handlers share one R (only the fallback widens it); IsLiteral is 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 and 1 | "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 PatternKey injective 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 dispatch throw is unreachable through the typed API; the throw tests widen the factory to Function to 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. HandledTags recovers the tag values the map handled (Member<Tags, keyof Handled>) and Narrowed<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, not Record<PropertyKey, unknown>. An interface has no implicit index signature, so the Record constraint would reject interface-based unions. The runtime reads the tag off object with one assertion, the tagged twin of the primitive-union dispatch's shape as string | number.
  • Narrowed distributes over T, narrowing K to the tag. A member whose K cannot take the tag drops out; a duplicated tag yields a union of members instead of dropping one. T[K] extends V returns 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 passed never. Matching a defined tag on an optional property ({ type?: "x" }) proves the key is present, so it becomes required ({ type: "x" }); the undefined tag narrows it to { type?: never } under exactOptionalPropertyTypes (absence) or { type?: undefined } when the property explicitly admits undefined.
  • A boolean / null / undefined tag goes through the shared PatternKey / Member projection. Discriminated admits those tags (they are in Tag), but they cannot key a mapped type, so the handler map is keyed by the stringified form (true → "true") and Member inverts 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 in matcher-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 and Member can no longer invert it; see § Supported universes. Pinned by the duplicate-tag tests in src/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 as String would. The shape as string | number assertion only placates TS2538 and buys the number fast path (an explicit String() defeats V8's numeric-key path: measured ~2× on number-keyed dispatch).
  • symbol/bigint/NaN/-0 are rejected, and a member colliding with its stringification is rejected by the gate: user-facing, stated once in README § Caveats.