diff --git a/CHANGELOG.md b/CHANGELOG.md index d342498..b17ef67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- move the `_` fallback to a second argument typed `Exclude`, so it receives only the unhandled keys, and infer handler returns from short, unannotated handlers + ## [0.3.0] - 2026-09-18 - condensed primitive matcher factories down to 2 from formerly 4 diff --git a/backlog.tasks b/backlog.tasks index 45ad514..d162e65 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -42,10 +42,10 @@ Matcher: ✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done → `getMatcher` / `getMatcherW`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing) → fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts` -☐ `_` should receive only the unhandled `T` keys, not all of `T` @medium - → today `_: (shape: T) => R`; desired `_: (shape: Exclude) => R` -☐ An exhaustive pattern that also carries `_` must be a compile error @medium - → `{ a, b, _ }` for `T = "a" | "b"` is accepted today; the redundant `_` should be rejected +✔ `_` should receive only the unhandled `T` keys, not all of `T` @medium @done + → the fallback is now a second argument: `(handlers, (s) => …)`, `s: Exclude` +☐ A fallback for an already-exhaustive handler map must be a compile error @medium + → `(handlers, fallback)` with `handlers` covering all of `T` is accepted; the redundant fallback should be rejected (currying would allow the guard) Bugs: ✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done diff --git a/development/library.md b/development/library.md index 5f89e22..4f71594 100644 --- a/development/library.md +++ b/development/library.md @@ -3,7 +3,7 @@ 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 +The matcher is implemented in `src/primitive.ts` and re-exported from `src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is placeholder code. @@ -11,81 +11,77 @@ placeholder code. #### Decision (2026-09) -A matcher is built by a factory and applied to a pattern: +A factory takes the universe and returns a builder; the builder takes a handler +map and an optional fallback: ```ts const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … }); +const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (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: +Exhaustive or fallback is decided **at the call site**, by whether the second +argument is present. The fallback's parameter is the remainder +`Exclude`. Only the return-strictness axis remains, so there +are two factories: -- `getMatcher` — one common `R`, the best common return type of every handler; -- `getMatcherW` — the union of every handler's return type. +- `getMatcher` — one common `R`; the fallback must fit it; +- `getMatcherW` — the union `PatternReturns | R`. -Both are three overloads whose order is load-bearing: +Each factory is two overloads whose order is load-bearing: -1. `ExhaustiveLoose` = `{ [K in T]: UnaryFn } & { _?: UnaryFn }` -2. `Fallback` = `Partial<…> & { _: UnaryFn }` -3. `Handlers` — the pure exhaustive shape +1. `Handlers` — the exhaustive form, and the contextual type of the + handler-map popup; +2. `Handled extends Exact>, Handled>` plus + `Fallback` — a partial handler map plus the fallback. #### 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. +- **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`. A later + argument is contextually typed from inference on an earlier one, so the split + is what makes the remainder expressible. +- **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>` 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 -- **Four factories** (exhaustive and fallback each split by return handling). - The exhaustive/fallback axis is expressible as one pattern type; four - signatures duplicate it. -- **Union merge** — one type `Exhaustive | (Partial<…> & { _: … })`, - explicit `()`. 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` sees only one arm. -- **Inferred universe** — `match(pattern)` with `T` taken from the keys - (exhaustive) or from `_`'s annotated parameter (fallback), via `NoInfer` and - `_?: never`, split by overloads (a plain union merges inference; measured - `T = "_" | "a"`). No explicit ``, 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)`. 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`). +- **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)`. `Handled` is fixed before + the second call, so a redundant-fallback guard would work. Rejected: two calls + for the common case. +- **`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 -- The `_` handler receives **all** of `T`, not the unhandled subset - (`Exclude`). -- 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 +- A redundant fallback is accepted: when the handler map already covers `T`, the + fallback is still allowed. The guard would be + `Exclude extends never ? never : unknown`, but the + conditional is evaluated before `Handled` is inferred; currying is the only + encoding that fixes it (see Rejected). +- `PatternReturns` must be `ReturnType, (...args: never[]) => unknown>>` so it survives the closed, partly-optional `P` constraints. - `Parameters[0]` resolves only the **last** overload, so it is diff --git a/src/primitive.test.ts b/src/primitive.test.ts index 6ec1899..3c445fe 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -22,11 +22,11 @@ test("getMatcher: exhaustive pattern infers one common return type", () => { // Act const matcher = factory({ - a: (s): number => { + a: (s) => { expectTypeOf(s).toEqualTypeOf<"a">(); return 1; }, - b: (s): 1 | 2 => { + b: (s) => { expectTypeOf(s).toEqualTypeOf<"b">(); return 2; }, @@ -69,19 +69,19 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => { // Act const matcher = factory({ - a: (s): number => { + a: (s) => { expectTypeOf(s).toEqualTypeOf<"a">(); return 1; }, - b: (s): 1 | 2 => { + b: (s) => { expectTypeOf(s).toEqualTypeOf<"b">(); return 2; }, - 1: (n): number => { + 1: (n) => { expectTypeOf(n).toEqualTypeOf<1>(); return 10; }, - 2: (n): number => { + 2: (n) => { expectTypeOf(n).toEqualTypeOf<2>(); return 20; }, @@ -99,22 +99,24 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => { // API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict // ============================================================================ -test("getMatcher: a `_` fallback makes the universe keys optional", () => { +test("getMatcher: a `_` fallback receives the unhandled keys", () => { // Arrange const factory = getMatcher<"a" | "b" | "c">(); // Act - const matcher = factory({ - a: (s): 1 | 2 => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return 1; + const matcher = factory( + { + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return 1 as const; + }, }, - _: (s): 1 | 2 => { - // The fallback sees the whole union, not a single literal. - expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">(); - return 2; + (s) => { + // The fallback sees only the keys `a` did not handle. + expectTypeOf(s).toEqualTypeOf<"b" | "c">(); + return 2 as const; }, - }); + ); // Assert expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>(); @@ -125,47 +127,27 @@ test("getMatcher: a `_` fallback makes the universe keys optional", () => { assert.equal(matcher("c"), 2); }); -test("getMatcher: a `_` fallback also accepts an exhaustive pattern", () => { - // Arrange - const factory = getMatcher<"a" | "b">(); - - // Act - const matcher = factory({ - a: (s) => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return "A"; - }, - b: (s) => { - expectTypeOf(s).toEqualTypeOf<"b">(); - return "B"; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => string>(); - assert.equal(matcher("a"), "A"); - assert.equal(matcher("b"), "B"); -}); - test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => { // Arrange const factory = getMatcher<"a" | "b" | 1 | 2>(); // Act - const matcher = factory({ - a: (s): string => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return "A"; + const matcher = factory( + { + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A"; + }, + 1: (n) => { + expectTypeOf(n).toEqualTypeOf<1>(); + return "one"; + }, }, - 1: (n): string => { - expectTypeOf(n).toEqualTypeOf<1>(); - return "one"; - }, - _: (s): string => { - expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); + (s) => { + expectTypeOf(s).toEqualTypeOf<"b" | 2>(); return "fallback"; }, - }); + ); // Assert expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>(); @@ -247,17 +229,19 @@ test("getMatcherW: a `_` fallback widens gaps into the union", () => { const factory = getMatcherW<"x" | "y" | "z">(); // Act - const matcher = factory({ - x: (s) => { - expectTypeOf(s).toEqualTypeOf<"x">(); - return 1 as const; + const matcher = factory( + { + x: (s) => { + expectTypeOf(s).toEqualTypeOf<"x">(); + return 1 as const; + }, }, - _: (s) => { - // The fallback sees the whole union, not a single literal. - expectTypeOf(s).toEqualTypeOf<"x" | "y" | "z">(); + (s) => { + // The fallback sees only the keys `x` did not handle. + expectTypeOf(s).toEqualTypeOf<"y" | "z">(); return "fallback" as const; }, - }); + ); // Assert expectTypeOf(matcher).toEqualTypeOf< @@ -275,20 +259,22 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () = const factory = getMatcherW<"a" | "b" | 1 | 2>(); // Act - const matcher = factory({ - a: (s) => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return "A" as const; + const matcher = factory( + { + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A" as const; + }, + 1: (n) => { + expectTypeOf(n).toEqualTypeOf<1>(); + return 10 as const; + }, }, - 1: (n) => { - expectTypeOf(n).toEqualTypeOf<1>(); - return 10 as const; - }, - _: (s) => { - expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); + (s) => { + expectTypeOf(s).toEqualTypeOf<"b" | 2>(); return "fallback" as const; }, - }); + ); // Assert // ❌ ReturnsStrict: mixed handler returns widen to their union. @@ -301,28 +287,6 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () = assert.equal(matcher(2), "fallback"); }); -test("getMatcherW: a `_` fallback also accepts an exhaustive pattern", () => { - // Arrange - const factory = getMatcherW<"x" | "y">(); - - // Act - const matcher = factory({ - x: (s) => { - expectTypeOf(s).toEqualTypeOf<"x">(); - return 1 as const; - }, - y: (s) => { - expectTypeOf(s).toEqualTypeOf<"y">(); - return "two" as const; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">(); - assert.equal(matcher("x"), 1); - assert.equal(matcher("y"), "two"); -}); - // ============================================================================ // Factory contracts — calls that must not compile // ============================================================================ @@ -341,8 +305,10 @@ test("getMatcher factory rejects patterns outside its contract", () => { // @ts-expect-error `c` is not part of the universe `"a" | "b"` c: () => 3, }); - // @ts-expect-error a gap without `_` is not exhaustive + // @ts-expect-error a gap without a `_` fallback is not exhaustive factory({ a: () => 1 }); + // @ts-expect-error a `string` fallback does not fit the `number` handler + factory({ a: () => 1 }, () => "x"); }); test("getMatcherW factory rejects patterns outside its contract", () => { @@ -350,16 +316,31 @@ test("getMatcherW factory rejects patterns outside its contract", () => { const factory = getMatcherW<"x" | "y">(); // Act / Assert — the calls below must not compile - // @ts-expect-error `z` is not part of the universe `"x" | "y"` factory({ x: () => 1 as const, y: () => 2 as const, + // @ts-expect-error `z` is not part of the universe `"x" | "y"` z: () => 3 as const, }); - // @ts-expect-error a gap without `_` is not exhaustive + // @ts-expect-error a gap without a `_` fallback is not exhaustive factory({ x: () => 1 as const }); }); +// ============================================================================ +// Dispatch — runtime behavior +// ============================================================================ + +test("getMatcher: an unhandled shape throws without a fallback", () => { + // Arrange — an open universe types its handler map as an index signature, + // so the type system cannot prove the runtime map is exhaustive. + const handlers: Record number> = { a: () => 1 }; + const matcher = getMatcher()(handlers); + + // Act / Assert + assert.equal(matcher("a"), 1); + assert.throws(() => matcher("b"), /Unhandled shape: b/); +}); + // ============================================================================ // Autocomplete — the language server is the oracle, not the type system // ============================================================================ @@ -374,11 +355,19 @@ const UNIVERSE = `"a" | "b" | "c"`; // One session per probe: the tests share no language-server state (open // documents, project membership), so they pass in any order. -const labelsFor = ( - name: string, - factory: "getMatcher" | "getMatcherW", - body: string, -): Promise => { +interface LabelsProbe { + readonly name: string; + readonly factory: "getMatcher" | "getMatcherW"; + readonly body: string; + readonly tail?: string; +} + +const labelsFor = ({ + name, + factory, + body, + tail = "", +}: LabelsProbe): Promise => { const session = new LspSession(REPO_ROOT); const target: CompletionTarget = { file: `src/__autocomplete_${name}.ts`, @@ -386,7 +375,7 @@ const labelsFor = ( `import { ${factory} } from "./index.ts";`, `const m = ${factory}<${UNIVERSE}>()({`, body, - "});", + `}${tail});`, "", ].join("\n"), }; @@ -396,16 +385,20 @@ const labelsFor = ( .finally(() => session.close()); }; -test("autocomplete: an exhaustive pattern requires the universe, `_` optional", () => { +test("autocomplete: an exhaustive pattern requires the universe", () => { // Arrange const name = "getMatcher_fresh"; // Act - const labels = labelsFor(name, "getMatcher", " /*COMPLETE*/"); + const labels = labelsFor({ + name, + factory: "getMatcher", + body: " /*COMPLETE*/", + }); // Assert return labels.then((result) => { - assert.deepEqual([...result], ["_?", "a", "b", "c"]); + assert.deepEqual([...result], ["a", "b", "c"]); }); }); @@ -414,28 +407,29 @@ test("autocomplete: handled keys drop out of the popup", () => { const name = "getMatcher_after_key"; // Act - const labels = labelsFor( + const labels = labelsFor({ name, - "getMatcher", - " a: () => 1,\n /*COMPLETE*/", - ); + factory: "getMatcher", + body: " a: () => 1,\n /*COMPLETE*/", + }); // Assert return labels.then((result) => { - assert.deepEqual([...result], ["_?", "b", "c"]); + assert.deepEqual([...result], ["b", "c"]); }); }); -test("autocomplete: `_` makes the remaining keys optional", () => { +test("autocomplete: a fallback makes the remaining keys optional", () => { // Arrange - const name = "getMatcher_after_fallback"; + const name = "getMatcher_with_fallback"; // Act - const labels = labelsFor( + const labels = labelsFor({ name, - "getMatcher", - " _: () => 0,\n /*COMPLETE*/", - ); + factory: "getMatcher", + body: " /*COMPLETE*/", + tail: ", () => 0", + }); // Assert return labels.then((result) => { @@ -443,15 +437,55 @@ test("autocomplete: `_` makes the remaining keys optional", () => { }); }); +test("autocomplete: with a fallback, handled keys stay optional", () => { + // Arrange + const name = "getMatcher_with_fallback_after_key"; + + // Act + const labels = labelsFor({ + name, + factory: "getMatcher", + body: " a: () => 1,\n /*COMPLETE*/", + tail: ", () => 0", + }); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["b?", "c?"]); + }); +}); + test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => { // Arrange const name = "getMatcherW_fresh"; // Act - const labels = labelsFor(name, "getMatcherW", " /*COMPLETE*/"); + const labels = labelsFor({ + name, + factory: "getMatcherW", + body: " /*COMPLETE*/", + }); // Assert return labels.then((result) => { - assert.deepEqual([...result], ["_?", "a", "b", "c"]); + assert.deepEqual([...result], ["a", "b", "c"]); + }); +}); + +test("autocomplete: `getMatcherW` also offers optional keys with a fallback", () => { + // Arrange + const name = "getMatcherW_with_fallback"; + + // Act + const labels = labelsFor({ + name, + factory: "getMatcherW", + body: " /*COMPLETE*/", + tail: ", () => 0", + }); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["a?", "b?", "c?"]); }); }); diff --git a/src/primitive.ts b/src/primitive.ts index 7c13a32..74f6c34 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -1,4 +1,4 @@ -import type { ValueOf } from "type-fest"; +import type { Exact, ValueOf } from "type-fest"; type UnaryFn = (shape: T) => R; @@ -8,54 +8,66 @@ type PatternReturns

= ReturnType< Extract, (...args: never[]) => unknown> >; -type Handlers = { [K in T]: UnaryFn }; +type Handlers = { [K in T]: UnaryFn }; -// Fallback form: `_` is a required key, the T-keys are optional. -type Fallback = Partial> & { - _: UnaryFn; -}; +// The fallback is a *second argument*, not a property of the handler map, +// because its parameter is the remainder `Exclude` and TypeScript +// fixes a property's contextual type before it infers its sibling keys. A later +// argument, by contrast, is contextually typed from inference on an earlier +// one, so the split is what makes the remainder expressible at all. +// See development/library.md. +type Fallback = UnaryFn< + Exclude, + R +>; -// Completion form: T-keys required, `_` optional. Overload #1, because -// TypeScript takes the *first* overload as the contextual type for the popup. -type ExhaustiveLoose = Handlers & { - _?: UnaryFn; -}; +// TypeScript does not apply the excess-property check to a generic constraint, +// so `Exact` restores it for the generic forms: a handler map can otherwise +// carry keys outside `T`. // oxlint-disable typescript/unified-signatures // Strict returns: one common `R`. Overload order is load-bearing: -// #1 ExhaustiveLoose -> autocomplete `_?, a, b` -// #2 Fallback -> accepts a partial pattern -// #3 Handlers (last) -> "Property 'b' is missing" is the reported error +// #1 Handlers (first) -> the exhaustive form and the autocomplete popup +// #2 Fallback (last) -> accepts a partial handler map plus a fallback interface MatcherStrict { - (pattern: ExhaustiveLoose): UnaryFn; - (pattern: Fallback): UnaryFn; - (pattern: Handlers): UnaryFn; + (handlers: Handlers): UnaryFn; + >, Handled>>( + handlers: Handled & Partial>, + fallback: Fallback, + ): UnaryFn; } // oxlint-enable typescript/unified-signatures // Widened returns: the union of every handler's return type. `P` is inferred -// from the whole parameter, whose closed constraint supplies the -// contextual/autocomplete type; the `keyof P` guard appended to each overload -// rejects keys outside `T`/`_`. +// from the whole handler map, whose closed constraint supplies the +// contextual/autocomplete type. +// oxlint-disable typescript/unified-signatures interface MatcherWidening { -

>( - pattern: P & (keyof P extends T | "_" ? unknown : never), - ): UnaryFn>; -

>( - pattern: P & (keyof P extends T | "_" ? unknown : never), - ): UnaryFn>; -

>( - pattern: P & (keyof P extends T | "_" ? unknown : never), +

, P>>( + handlers: P, ): UnaryFn>; + >, Handled>>( + handlers: Handled, + fallback: Fallback, + ): UnaryFn | R>; } +// oxlint-enable typescript/unified-signatures type HandlerMap = Record | undefined>; const dispatch = - (pattern: HandlerMap) => + (handlers: HandlerMap, fallback?: UnaryFn) => (shape: string | number): unknown => - // oxlint-disable-next-line typescript/no-non-null-assertion typescript/no-unsafe-type-assertion - (pattern[shape] ?? pattern["_"]!)(shape as never); + ( + handlers[shape] ?? + fallback ?? + (() => { + throw new Error(`Unhandled shape: ${shape}`); + }) + )( + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + shape as never, + ); export const getMatcher = (): MatcherStrict => dispatch;