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