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

93 lines
4.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Library design
The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#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:
```ts
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).