Files
tiny-pattern-ts/development/library.md
T
tmu 5ddbbdc183 ♻️ Keep the public API to the four factories
The builder types are already the inferred return types and travel into
the emitted .d.ts, so exporting them only made them nameable while
pinning the internal Strict/Widening split as API. Revert the type
exports and the public renames, drop the type-surface test, and document
only the factories in README and development/library.md.
2026-09-23 22:19:02 +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, 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 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.