Files
tiny-pattern-ts/development/library.md
T
tmu 05fad0fcd6 📝 Record the autocomplete and matcher-design decisions
The matcher is now two three-overload factories; library.md documents why
the union merge, inferred universe, conditional RequireKeys and cases-first
paths were rejected, and lists the two open issues (the fallback sees all of
T; a redundant _ is still accepted). testing.md records the language server
as the autocomplete oracle, and CONTRIBUTING points the exception at the
matcher's own test file.

The backlog marks the design-doc and autocomplete groundwork done alongside
the adoption.
2026-09-17 20:59:28 +00:00

4.7 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 matcher below is implemented in src/primitive.ts and re-exported from src/index.ts; the rest of the library is placeholder code.

Matcher shape

Decision (2026-09)

A matcher is built by a factory and applied to a pattern:

const matcher = strict<"a" | "b">()({ a: (s) => …, b: (s) => … });

Whether the pattern is exhaustive or has a fallback is decided at the call site, by whether it carries _ — F#'s | _ ->. Only the return-strictness axis remains, so there are two factories:

  • strict — one common R, the best common return type of every handler;
  • widened — the union of every handler's return type.

Both are three overloads whose order is load-bearing:

  1. ExhaustiveLoose<R, T> = { [K in T]: UnaryFn<K, R> } & { _?: UnaryFn<T, R> }
  2. Fallback<R, T> = Partial<…> & { _: UnaryFn<T, R> }
  3. Handlers<R, T> — the pure exhaustive shape

Why

  • Autocomplete reads the first overload, the error reads the last. TypeScript takes the first overload signature as the contextual type for the object-literal popup, and the last for No overload matches this call. So ExhaustiveLoose first yields the popup _?, a, b (T-keys required, _ optional) while Handlers last yields Property 'b' is missing. The two can be tuned independently.
  • Two factories, not four: the fallback is a pattern shape, not a separate API.
  • The widened return union is derived from the pattern's handler types, so it needs no fourth signature.

Rejected

  • Four factories (src/primitive.ts today: exhaustive × fallback × strict/widened). The exhaustive/fallback axis is expressible as one pattern type; four signatures duplicate it.
  • Union merge — one type Exhaustive<R,T> | (Partial<…> & { _: … }), explicit <T>(). Type-safe and completable, but TypeScript reports the near-miss union member, so a missing key reads Property '_' is missing instead of naming the key. Arm order does not change the report; the overload split does.
  • Overload merge with only the exhaustive arm last. Fixes the missing-key message, but a wrong _ parameter is then reported against the exhaustive arm, and Parameters<typeof factory> sees only one arm.
  • Inferred universe — match(pattern) with T taken from the keys (exhaustive) or from _'s annotated parameter (fallback), via NoInfer<T> and _?: never, split by overloads (a plain union merges inference; measured T = "_" | "a"). No explicit <T>, and pipe-friendly. Rejected because: with no declared universe the exhaustive popup offers only _; an unannotated _ widens T to string | number; and NoInfer leaks into the emitted .d.ts, raising the consumer floor to TypeScript 5.4 (README promises >= 5.0).
  • Conditional RequireKeys — parameter P & ("_" extends keyof P ? unknown : Handlers<R, T>). Gives the good missing-key message, but keyof P counts optional keys: a widened value whose declared type has _?: bypasses the completeness check. Demanding a required _ instead rejects that case but breaks P inference — P falls back to its constraint and partial literals then demand every key. Typos also need a NoExtra guard, whose message degrades to not assignable to never.
  • Cases-first curried — match(["a", "b"])({ a: …, b: … }). Completion works for exhaustive patterns, and the array is a single source of truth for the runtime list and the union. Rejected as not pipe-friendly; it needs a runtime array; and the single-call form match(cases, pattern) cannot infer R (the mapped key type K[number] stays deferred, so R widens to unknown).

Known issue

  • The _ handler receives all of T, not the unhandled subset (Exclude<T, handledKeys>).
  • An exhaustive pattern that also carries _ is accepted; the redundant _ should be a compile error.
  • The widened overloads carry a completeness guard keyof P extends T | "_" ? unknown : never, because TypeScript does not apply the excess-property check to a generic constraint: a generic parameter accepts extra keys, a parameter typed as a concrete object type does not. 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).