diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbfe07..9c88904 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] +- add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions + ## [0.5.0] - 2026-09-21 - reject a fallback when the handler map already covers the universe diff --git a/backlog.tasks b/backlog.tasks index e17212f..dc6eff8 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -37,6 +37,8 @@ Testing: ✔ Rewrite `src/util/__tests__/lsp-completion.ts` onto `createMessageConnection` and typed requests @done ✔ Update `development/testing.md § Autocomplete` for the new client @done ✔ Run `npm run verify` and check the task off @done +☐ Test a literal union widened with `(string & {})` — `"red" | "green" | "yellow" | (string & {})` @medium + → autocomplete should still offer the literals; arbitrary strings stay assignable and fall through to the fallback Matcher: ✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done @@ -46,6 +48,13 @@ Matcher: → 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 @done → rejected by an F-bounded constraint on `Handled` (checked *after* inference); a conditional in the fallback parameter is evaluated too early and breaks contextual typing +✔ Implement matcher with similar API like matcher from primitive.ts @high @done + → `getTaggedUnionMatcher` / `getTaggedUnionMatcherW`, curried on the discriminant key + → fallback is the second argument; `_` removed +☐ Rename `primitive` to `primitive-union` and `getMatcher` to `getPrimitiveMatcher` @medium + → `src/primitive.ts` / `src/primitive.test.ts` → `primitive-union.*` + → `getMatcherW` → `getPrimitiveMatcherW` for symmetry with the tagged-union pair + → update `src/index.ts`, `development/library.md` and any README references Bugs: ✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done @@ -55,6 +64,8 @@ Enhancements: ✔ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @medium @done ✔ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium @done → added boolean, null and undefined; rejected `symbol` (compile-time brand, nothing at runtime) and `bigint` (not a property key) +☐ Allow boolean, null and undefined discriminant values in tagged-union patterns @medium + → needs the primitive matcher's `PatternKey` / `PatternParam` projection; see development/library.md § Tagged-union matcher Documentation: ☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place diff --git a/development/library.md b/development/library.md index 7c6b5fe..0624bb7 100644 --- a/development/library.md +++ b/development/library.md @@ -3,9 +3,10 @@ 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 is implemented in `src/primitive.ts` and re-exported from -`src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is -placeholder code. +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 @@ -92,6 +93,75 @@ Each factory is two overloads whose order is load-bearing: 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` 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: + +```ts +type Shape = + | { kind: "circle"; radius: number } + | { kind: "square"; side: number }; + +const area = getTaggedUnionMatcher()("kind")({ + circle: (s) => Math.PI * s.radius ** 2, + square: (s) => s.side ** 2, +}); +const fallback = getTaggedUnionMatcher()("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` 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` 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`.** 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) diff --git a/src/index.ts b/src/index.ts index 413625f..442ad4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,5 @@ export { getMatcher, getMatcherW } from "./primitive.ts"; +export { + getTaggedUnionMatcher, + getTaggedUnionMatcherW, +} from "./tagged-union.ts"; diff --git a/src/matcher-shared.ts b/src/matcher-shared.ts new file mode 100644 index 0000000..f1fa22e --- /dev/null +++ b/src/matcher-shared.ts @@ -0,0 +1,28 @@ +import type { ValueOf } from "type-fest"; + +// The primitive and tagged-union matchers differ in their universe, but the +// handler/fallback plumbing is identical; these are the shared pieces. The +// boundary is deliberate: `Handlers`, `Fallback` and `MustBePartial` stay with +// each matcher because they are built from its universe. See development/library.md. + +// A handler: one universe member in, one return value out. +export type UnaryFn = (shape: T) => R; + +// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works +// when `P`'s constraint has optional keys. +export type PatternReturns

= ReturnType< + Extract, (...args: never[]) => unknown> +>; + +// The diagnostic raised when a fallback is supplied for an already-exhaustive +// handler map. Each matcher's `MustBePartial` folds it into `Handled`'s +// constraint so the guard is checked after inference. +export interface RedundantFallback { + readonly "every case is already handled, so the fallback is redundant": never; +} + +// The runtime dispatch map the handler maps and fallback erase to. +export type HandlerMap = Record< + string | number, + UnaryFn | undefined +>; diff --git a/src/primitive.ts b/src/primitive.ts index 7919d2c..a8081e1 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -1,6 +1,11 @@ -import type { Exact, ValueOf } from "type-fest"; +import type { Exact } from "type-fest"; -type UnaryFn = (shape: T) => R; +import type { + HandlerMap, + PatternReturns, + RedundantFallback, + UnaryFn, +} from "./matcher-shared.ts"; // The primitive universe a matcher can discriminate. `boolean` is admitted as // the pair `true | false`; see README § Caveats for the unsupported members. @@ -29,12 +34,6 @@ type PatternParam = K extends "true" ? undefined : K; -// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works -// when `P`'s constraint has optional keys. -type PatternReturns

= ReturnType< - Extract, (...args: never[]) => unknown> ->; - type Handlers = { [K in PatternKey]: UnaryFn, R>; }; @@ -58,9 +57,6 @@ type Fallback = UnaryFn< // failure is reported on the argument that inferred `Handled` (the handler // map), so the required property is spelled as the message instead of relying // on its position. See development/library.md. -interface RedundantFallback { - readonly "every case is already handled, so the fallback is redundant": never; -} type MustBePartial = PatternKey extends keyof Handled ? RedundantFallback : unknown; @@ -104,8 +100,6 @@ interface MatcherWidening { } // oxlint-enable typescript/unified-signatures -type HandlerMap = Record | undefined>; - const dispatch = (handlers: HandlerMap, fallback?: UnaryFn) => (shape: Matchable): unknown => diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts new file mode 100644 index 0000000..2027f71 --- /dev/null +++ b/src/tagged-union.test.ts @@ -0,0 +1,452 @@ +import { strict as assert } from "node:assert"; +import path from "node:path"; +import { test } from "node:test"; + +import { expectTypeOf } from "expect-type"; + +import { + type CompletionTarget, + LspSession, +} from "#test-utils/lsp-completion.ts"; + +import { + getTaggedUnionMatcher, + getTaggedUnionMatcherW, +} from "./tagged-union.ts"; + +interface Circle { + readonly kind: "circle"; + readonly radius: number; +} +interface Square { + readonly kind: "square"; + readonly side: number; +} +interface Triangle { + readonly kind: "triangle"; + readonly base: number; + readonly height: number; +} +type Shape = Circle | Square | Triangle; + +// ============================================================================ +// API: getTaggedUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict +// ============================================================================ + +test("getTaggedUnionMatcher: exhaustive pattern infers one common return type", () => { + // Arrange + const factory = getTaggedUnionMatcher()("kind"); + + // Act + const area = factory({ + circle: (s) => { + // Each handler receives the member its tag selects, not the union. + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "circle"); + return Math.PI * s.radius ** 2; + }, + square: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "square"); + return s.side ** 2; + }, + triangle: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "triangle"); + return (s.base * s.height) / 2; + }, + }); + + // Assert + // ✔️ ReturnsStrict: R is the best common return type, not a widening union. + expectTypeOf(area).toEqualTypeOf<(shape: Shape) => number>(); + assert.equal(area({ kind: "square", side: 2 }), 4); + assert.equal(area({ kind: "triangle", base: 2, height: 3 }), 3); +}); + +test("getTaggedUnionMatcher: dispatches on numeric tags", () => { + // Arrange + type Version = + | { readonly kind: 1; readonly a: number } + | { + readonly kind: 2; + readonly b: number; + }; + const factory = getTaggedUnionMatcher()("kind"); + + // Act + const pick = factory({ + 1: (v) => { + expectTypeOf(v).toEqualTypeOf<{ + readonly kind: 1; + readonly a: number; + }>(); + assert.equal(v.kind, 1); + return v.a; + }, + 2: (v) => { + expectTypeOf(v).toEqualTypeOf<{ + readonly kind: 2; + readonly b: number; + }>(); + assert.equal(v.kind, 2); + return v.b; + }, + }); + + // Assert + expectTypeOf(pick).toEqualTypeOf<(shape: Version) => number>(); + assert.equal(pick({ kind: 1, a: 10 }), 10); + assert.equal(pick({ kind: 2, b: 20 }), 20); +}); + +// ============================================================================ +// API: getTaggedUnionMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict +// ============================================================================ + +test("getTaggedUnionMatcher: a fallback receives the unhandled members", () => { + // Arrange + const factory = getTaggedUnionMatcher()("kind"); + + // Act + const area = factory( + { + circle: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "circle"); + return 1 as const; + }, + }, + (s) => { + // The fallback sees only the members `circle` did not handle. + expectTypeOf(s).toEqualTypeOf(); + assert.ok(s.kind === "square" || s.kind === "triangle"); + return 2 as const; + }, + ); + + // Assert + expectTypeOf(area).toEqualTypeOf<(shape: Shape) => 1 | 2>(); + assert.equal(area({ kind: "circle", radius: 1 }), 1); + assert.equal(area({ kind: "square", side: 1 }), 2); + assert.equal(area({ kind: "triangle", base: 1, height: 1 }), 2); +}); + +test("getTaggedUnionMatcher: an open discriminant keeps the fallback open", () => { + // Arrange + interface Message { + readonly kind: string; + readonly text: string; + } + const factory = getTaggedUnionMatcher()("kind"); + + // Act + const matcher = factory({ info: () => 1 as const }, (s) => { + // The map's literal key does not close an open discriminant, so the + // remainder stays `Message` and the fallback is not redundant. + expectTypeOf(s).toEqualTypeOf(); + assert.equal(typeof s.text, "string"); + return 2 as const; + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: Message) => 1 | 2>(); + assert.equal(matcher({ kind: "info", text: "" }), 1); + assert.equal(matcher({ kind: "warn", text: "" }), 2); +}); + +// ============================================================================ +// API: getTaggedUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict +// ============================================================================ + +test("getTaggedUnionMatcherW: exhaustive pattern widens to the union of returns", () => { + // Arrange + const factory = getTaggedUnionMatcherW()("kind"); + + // Act + const describe = factory({ + circle: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "circle"); + return "round" as const; + }, + square: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "square"); + return 4 as const; + }, + triangle: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.kind, "triangle"); + return true as const; + }, + }); + + // Assert + // ❌ ReturnsStrict: mixed handler returns widen to their union. + expectTypeOf(describe).toEqualTypeOf< + (shape: Shape) => "round" | 4 | true + >(); + assert.equal(describe({ kind: "circle", radius: 1 }), "round"); + assert.equal(describe({ kind: "square", side: 1 }), 4); + assert.equal(describe({ kind: "triangle", base: 1, height: 1 }), true); +}); + +// ============================================================================ +// API: getTaggedUnionMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict +// ============================================================================ + +test("getTaggedUnionMatcherW: a fallback widens gaps into the union", () => { + // Arrange + const factory = getTaggedUnionMatcherW()("kind"); + + // Act + const matcher = factory({ circle: () => 1 as const }, (s) => { + expectTypeOf(s).toEqualTypeOf(); + // Returning the member verbatim lets the `Assert` block check the + // exact value dispatch passed. + return s; + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf< + (shape: Shape) => 1 | Square | Triangle + >(); + assert.equal(matcher({ kind: "circle", radius: 1 }), 1); + assert.deepEqual(matcher({ kind: "square", side: 1 }), { + kind: "square", + side: 1, + }); + assert.deepEqual(matcher({ kind: "triangle", base: 1, height: 1 }), { + kind: "triangle", + base: 1, + height: 1, + }); +}); + +// ============================================================================ +// Factory contracts — calls that must not compile +// ============================================================================ + +test("getTaggedUnionMatcher factory rejects patterns outside its contract", () => { + // Arrange + const factory = getTaggedUnionMatcher()("kind"); + + // Act / Assert — the calls below must not compile + factory({ + circle: () => 1, + square: () => 2, + triangle: () => 3, + // @ts-expect-error `hexagon` is not a tag of Shape + hexagon: () => 4, + }); + // @ts-expect-error a gap without a fallback is not exhaustive + factory({ circle: () => 1 }); + // @ts-expect-error a `string` fallback does not fit the `number` handlers + factory({ circle: () => 1 }, () => "x"); + // @ts-expect-error a fallback is redundant once the map covers the union + factory({ circle: () => 1, square: () => 2, triangle: () => 3 }, () => 0); +}); + +test("getTaggedUnionMatcher factory rejects a non-discriminant key", () => { + // Arrange + type Mixed = + | { readonly id: Date; readonly kind: "a" } + | { readonly id: Date; readonly kind: "b" }; + + // Act / Assert — the calls below must not compile + // @ts-expect-error `id` is a common key but its value is not a tag + getTaggedUnionMatcher()("id"); + getTaggedUnionMatcher()("kind"); +}); + +test("getTaggedUnionMatcherW factory rejects patterns outside its contract", () => { + // Arrange + const factory = getTaggedUnionMatcherW()("kind"); + + // Act / Assert — the calls below must not compile + factory({ + circle: () => 1 as const, + square: () => 2 as const, + triangle: () => 3 as const, + // @ts-expect-error `hexagon` is not a tag of Shape + hexagon: () => 4 as const, + }); + // @ts-expect-error a gap without a fallback is not exhaustive + factory({ circle: () => 1 as const }); + factory( + // @ts-expect-error a fallback is redundant once the map covers the union + { + circle: () => 1 as const, + square: () => 2 as const, + triangle: () => 3 as const, + }, + () => 0, + ); +}); + +// ============================================================================ +// Dispatch — runtime behavior +// ============================================================================ + +test("getTaggedUnionMatcher: an unhandled tag throws without a fallback", () => { + // Arrange — an open discriminant widens its handler map to an index + // signature, so the type system cannot prove the runtime map is exhaustive. + interface Message { + readonly kind: string; + } + const handlers: Record number> = { + info: (shape) => { + // The full member reaches the handler, not its tag. + assert.equal(shape.kind, "info"); + return 1; + }, + }; + const matcher = getTaggedUnionMatcher()("kind")(handlers); + + // Act / Assert + assert.equal(matcher({ kind: "info" }), 1); + assert.throws(() => matcher({ kind: "warn" }), /Unhandled tag: warn/); +}); + +// ============================================================================ +// Autocomplete — the language server is the oracle, not the type system +// ============================================================================ + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const SHAPE_SOURCE = `type Shape = { kind: "a"; a: number } | { kind: "b"; b: number } | { kind: "c"; c: number };`; + +interface LabelsProbe { + readonly name: string; + readonly factory: "getTaggedUnionMatcher" | "getTaggedUnionMatcherW"; + 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`, + source: [ + `import { ${factory} } from "./index.ts";`, + SHAPE_SOURCE, + `const m = ${factory}()("kind")({`, + body, + `}${tail});`, + "", + ].join("\n"), + }; + return session + .completionLabelsAt(target) + .then((result) => result.labels) + .finally(() => session.close()); +}; + +test("autocomplete: a tagged-union pattern requires the tags", () => { + // Arrange + const name = "tagged_union_fresh"; + + // Act + const labels = labelsFor({ + name, + factory: "getTaggedUnionMatcher", + body: " /*COMPLETE*/", + }); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["a", "b", "c"]); + }); +}); + +test("autocomplete: a handled tag drops out of the popup", () => { + // Arrange + const name = "tagged_union_after_key"; + + // Act + const labels = labelsFor({ + name, + factory: "getTaggedUnionMatcher", + body: " a: () => 1,\n /*COMPLETE*/", + }); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["b", "c"]); + }); +}); + +test("autocomplete: a fallback makes the remaining tags optional", () => { + // Arrange + const name = "tagged_union_with_fallback"; + + // Act + const labels = labelsFor({ + name, + factory: "getTaggedUnionMatcher", + body: " /*COMPLETE*/", + tail: ", () => 0", + }); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["a?", "b?", "c?"]); + }); +}); + +// `Mixed` has two common keys, but `id`'s value is not a tag, so only `kind` +// may serve as the discriminant. +const MIXED_SOURCE = `type Mixed = { id: Date; kind: "a"; a: number } | { id: Date; kind: "b"; b: number };`; + +const discriminantLabelsFor = ({ + name, + factory, + typeName, + typeSource, +}: { + readonly name: string; + readonly factory: "getTaggedUnionMatcher" | "getTaggedUnionMatcherW"; + readonly typeName: string; + readonly typeSource: string; +}): Promise => { + const session = new LspSession(REPO_ROOT); + const target: CompletionTarget = { + file: `src/__autocomplete_${name}.ts`, + source: [ + `import { ${factory} } from "./index.ts";`, + typeSource, + `const m = ${factory}<${typeName}>()(/*COMPLETE*/);`, + "", + ].join("\n"), + }; + return session + .completionLabelsAt(target) + .then((result) => result.labels) + .finally(() => session.close()); +}; + +test("autocomplete: the discriminant key is offered at the key argument", () => { + // Arrange + const name = "tagged_union_discriminant"; + + // Act + const labels = discriminantLabelsFor({ + name, + factory: "getTaggedUnionMatcher", + typeName: "Mixed", + typeSource: MIXED_SOURCE, + }); + + // Assert + return labels.then((result) => { + // The argument position also offers every global identifier, so assert + // inclusion of the discriminant and exclusion of the non-tag key. + assert.ok(result.includes('"kind"')); + assert.ok(!result.includes('"id"')); + }); +}); diff --git a/src/tagged-union.ts b/src/tagged-union.ts new file mode 100644 index 0000000..459024b --- /dev/null +++ b/src/tagged-union.ts @@ -0,0 +1,124 @@ +import type { Exact, UnknownRecord } from "type-fest"; + +import type { + HandlerMap, + PatternReturns, + RedundantFallback, + UnaryFn, +} from "./matcher-shared.ts"; + +// A tagged union is discriminated by one property whose values are the tags. +// Only `string` and `number` tags can key a handler map: `symbol` has no +// literal syntax to write a handler under, and `bigint` is not a property key. +type Tag = string | number; + +// The discriminant values of `T` under `K`. `Extract` keeps the finite literal +// tags and leaves a widened `string`/`number` as itself, so an open universe +// keeps an open fallback. +type Tags = Extract; + +// The keys of `T` that can act as a discriminant. `getTaggedUnionMatcher()` +// accepts only these, so the factory rejects a key whose values are not tags. +type Discriminated = { + [K in keyof T]: T[K] extends Tag ? K : never; +}[keyof T]; + +// The member(s) of `T` tagged `V`. `Extract` distributes over the union, so a +// duplicated tag maps to a union of members rather than silently dropping one. +type MapTaggedUnion = { + [V in Tags]: Extract>; +}; + +type Handlers = { + [V in Tags]: UnaryFn[V], R>; +}; + +// The members `Handled` covers. Mapping over `Tags` keeps every index within +// `MapTaggedUnion`'s keys, and the conditional drops a stray key outside `T` so +// it cannot widen the remainder. The remainder is `Exclude`, mirroring +// the primitive matcher's `Exclude>`. +type HandledMembers = { + [V in Tags]: V extends keyof Handled + ? MapTaggedUnion[V] + : never; +}[Tags]; + +// The fallback is a *second argument*, not a property of the handler map, so +// its parameter can be the remainder the map left open. See development/library.md. +type Fallback = UnaryFn< + Exclude>, + R +>; + +// A fallback is redundant once the handler map covers every tag of `T`. Folded +// into `Handled`'s own (self-referential) constraint so it is checked *after* +// inference; see the primitive matcher for why a conditional in the fallback's +// parameter is evaluated too early. +type MustBePartial = + Tags extends keyof Handled ? RedundantFallback : unknown; + +// TypeScript does not apply the excess-property check to a generic constraint, +// so `Exact` restores it for the generic forms. + +// Strict returns: one common `R`. Overload order is load-bearing: +// #1 Handlers (first) -> the exhaustive form and the autocomplete popup +// #2 Fallback (last) -> accepts a partial handler map plus a fallback +interface TaggedUnionMatcherStrict { + (handlers: Handlers): UnaryFn; + < + R, + Handled extends Exact>, Handled> & + MustBePartial, + >( + handlers: Handled & Partial>, + fallback: Fallback, + ): UnaryFn; +} + +// Widened returns: the union of every handler's return type. `P` is inferred +// from the whole handler map, whose closed constraint supplies the +// contextual/autocomplete type. +interface TaggedUnionMatcherWidening { +

, P>>( + handlers: P, + ): UnaryFn>; + < + R, + Handled extends Exact>, Handled> & + MustBePartial, + >( + handlers: Handled, + fallback: Fallback, + ): UnaryFn | R>; +} + +const dispatch = + (k: PropertyKey) => + (handlers: HandlerMap, fallback?: UnaryFn) => + (shape: object): unknown => { + // `object` carries no index signature, so the read needs the assertion; + // the factory admits only keys whose values are `string | number` tags, + // so the result is narrowed to the map's key space. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + const tag = (shape as UnknownRecord)[k] as string | number; + return ( + handlers[tag] ?? + fallback ?? + (() => { + throw new Error(`Unhandled tag: ${String(tag)}`); + }) + )( + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + shape as never, + ); + }; + +export const getTaggedUnionMatcher = + () => + >(k: K): TaggedUnionMatcherStrict => + dispatch(k); + +export const getTaggedUnionMatcherW = + () => + >(k: K): TaggedUnionMatcherWidening => + dispatch(k);