Files
tiny-pattern-ts/development/library.md
T

9.4 KiB
Raw 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 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 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 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.
  • 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 and the PatternKey / PatternParam projection 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. HandledMembers maps the handled tags to their members and Exclude<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, 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.
  • MapTaggedUnion distributes with Extract. A duplicated tag yields a union of members instead of dropping one.
  • A boolean / null / undefined tag goes through the shared PatternKey / PatternParam 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 PatternParam inverts it 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 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 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 unguarded: user-facing, stated once in README § Caveats.