From 937d84ea48d3a95a29851ece03b714cb0e4e75cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 21 Sep 2026 23:15:09 +0000 Subject: [PATCH] :recycle: Share matcher internals --- development/library.md | 21 +++++++++++++++++++++ src/matcher-shared.ts | 28 ++++++++++++++++++++++++++++ src/primitive.ts | 20 +++++++------------- src/tagged-union.ts | 20 +++++++------------- 4 files changed, 63 insertions(+), 26 deletions(-) create mode 100644 src/matcher-shared.ts diff --git a/development/library.md b/development/library.md index f5b2cd8..0624bb7 100644 --- a/development/library.md +++ b/development/library.md @@ -93,6 +93,27 @@ 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) 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.ts b/src/tagged-union.ts index 3071b45..459024b 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -1,6 +1,11 @@ -import type { Exact, UnknownRecord, ValueOf } from "type-fest"; +import type { Exact, UnknownRecord } from "type-fest"; -type UnaryFn = (shape: T) => R; +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 @@ -28,12 +33,6 @@ type Handlers = { [V in Tags]: UnaryFn[V], R>; }; -// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works -// when `P`'s constraint has optional keys. -type PatternReturns

= ReturnType< - Extract, (...args: never[]) => unknown> ->; - // 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 @@ -55,9 +54,6 @@ type Fallback = UnaryFn< // 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. -interface RedundantFallback { - readonly "every case is already handled, so the fallback is redundant": never; -} type MustBePartial = Tags extends keyof Handled ? RedundantFallback : unknown; @@ -96,8 +92,6 @@ interface TaggedUnionMatcherWidening { ): UnaryFn | R>; } -type HandlerMap = Record | undefined>; - const dispatch = (k: PropertyKey) => (handlers: HandlerMap, fallback?: UnaryFn) =>