Files
tiny-pattern-ts/development/library.md
T
2026-09-21 23:15:09 +00:00

8.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.ts (getMatcher / getMatcherW) 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 = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getMatcher<"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:

  • getMatcher — one common R; the fallback must fit it;
  • getMatcherW — 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 four universe-agnostic pieces both matchers use: UnaryFn, PatternReturns, RedundantFallback, HandlerMap.

Why

  • RedundantFallback's property name is the diagnostic, so one definition keeps the two matchers' message from drifting; the other three appear verbatim in both public signatures.

Rejected

  • A generic Matcher<Universe> over the interface pair, Handlers, Fallback and MustBePartial. Each is built from its own universe (PatternKey/PatternParam vs Tags/MapTaggedUnion); abstracting over the F-bounded Handled constraint that makes the remainder work risks the contextual typing it exists to preserve.

Tagged-union matcher

Decision (2026-09)

getTaggedUnionMatcher / getTaggedUnionMatcherW mirror the primitive 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 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 dispatch's shape as string | number.
  • MapTaggedUnion distributes with Extract. A duplicated tag yields a union of members instead of dropping one.

Known issue

  • Tags are string | number only. A boolean / null / undefined discriminant ({ ok: true } | { ok: false }) is rejected by Discriminated, because those values are not property keys; supporting them needs the primitive matcher's PatternKey / PatternParam projection.
  • A member's tag must be unique across the union; two members with the same tag collapse to a union under one handler.

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).

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.