From e96ca12149de8df9fb47137c842a3961e5a27e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 22 Sep 2026 10:22:59 +0000 Subject: [PATCH 1/8] :sparkles: Allow boolean and nullish tags in tagged unions A `boolean` / `null` / `undefined` discriminant cannot key a handler map, so the tagged-union matcher now routes it through the `PatternKey` / `PatternParam` projection the primitive-union matcher already used. Both matchers share the projection from `matcher-shared.ts`; `MapTaggedUnion`, `Handlers`, `HandledMembers` and `MustBePartial` key off the projected form and invert it to recover the real member. Covers exhaustive and fallback dispatch, the redundant-fallback guard, and the LSP popup offering the tags by name. --- development/library.md | 32 ++++--- src/matcher-shared.ts | 26 ++++++ src/primitive-union.ts | 25 +----- src/tagged-union.test.ts | 175 ++++++++++++++++++++++++++++++++++++++- src/tagged-union.ts | 37 +++++---- 5 files changed, 243 insertions(+), 52 deletions(-) diff --git a/development/library.md b/development/library.md index 4b0698a..49ec1db 100644 --- a/development/library.md +++ b/development/library.md @@ -97,22 +97,25 @@ Each factory is two overloads whose order is load-bearing: #### Decision (2026-09) -`src/matcher-shared.ts` holds the four universe-agnostic pieces both matchers -use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`. +`src/matcher-shared.ts` holds the six universe-agnostic pieces both matchers +use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, and the +`PatternKey` / `PatternParam` key projection. #### 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. + keeps the two matchers' message from drifting; the other pieces appear + verbatim in both public signatures or are the same projection over each + matcher's universe. #### 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 + (`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the F-bounded `Handled` constraint that makes the remainder work risks the - contextual typing it exists to preserve. + contextual typing it exists to preserve. The `PatternKey` / `PatternParam` + projection is the one piece both universes genuinely share. ## Tagged-union matcher @@ -152,15 +155,18 @@ The key is a separate call because `K` is inferred from its literal argument and assertion, the tagged twin of the primitive-union dispatch's `shape as string | number`. - **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a union of members instead of dropping one. +- **A `boolean` / `null` / `undefined` tag goes through the shared + `PatternKey` / `PatternParam` projection.** `Discriminated` admits those tags + (they are in `Tag`), but they cannot key a mapped type, so the handler map is + keyed by the stringified form (`true` → `"true"`) and `PatternParam` inverts + it to recover the member. This is the same projection the primitive-union + matcher uses over its universe, which is why it lives in `matcher-shared.ts`. #### 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-union 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. + collapse to a union under one handler. The same holds for a tag colliding with + its stringification (`true | "true"`) — see [README § Caveats](../README.md#caveats). ## Primitive universe @@ -172,7 +178,9 @@ with `boolean` admitted as `true | false`. `boolean`/`null`/`undefined` are not property keys, so handler-map keys are a projection (`PatternKey`: each member stringified) and `PatternParam` inverts it, so callbacks receive the real member (`true`, not `"true"`). The popup -offers `true`, `false`, `null`, `undefined` by name (verified over LSP). +offers `true`, `false`, `null`, `undefined` by name (verified over LSP). The +same projection is shared with the tagged-union matcher; see +§ Tagged-union matcher. #### Why diff --git a/src/matcher-shared.ts b/src/matcher-shared.ts index 767c543..45dc574 100644 --- a/src/matcher-shared.ts +++ b/src/matcher-shared.ts @@ -8,6 +8,32 @@ import type { ValueOf } from "type-fest"; // A handler: one universe member in, one return value out. export type UnaryFn = (shape: T) => R; +// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type +// over a universe that includes one keys each such member by its +// stringification. `PatternParam` inverts that projection, so a handler callback +// still receives the *real* member (`true`, not `"true"`). Both matchers use the +// projection: the primitive-union matcher over its universe, the tagged-union +// matcher over a discriminant property's values. See README § Caveats for the +// limits. +export type PatternKey = T extends boolean + ? T extends true + ? "true" + : "false" + : T extends null + ? "null" + : T extends undefined + ? "undefined" + : T; +export type PatternParam = K extends "true" + ? true + : K extends "false" + ? false + : K extends "null" + ? null + : K extends "undefined" + ? undefined + : K; + // `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works // when `P`'s constraint has optional keys. export type PatternReturns

= ReturnType< diff --git a/src/primitive-union.ts b/src/primitive-union.ts index afd4a01..76b58d8 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -2,6 +2,8 @@ import type { Exact } from "type-fest"; import type { HandlerMap, + PatternKey, + PatternParam, PatternReturns, RedundantFallback, UnaryFn, @@ -11,29 +13,6 @@ import type { // the pair `true | false`; see README § Caveats for the unsupported members. type Matchable = string | number | boolean | null | undefined; -// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type -// over the universe keys each non-key member by its stringification. `Param` -// inverts that projection, so a handler callback still receives the *real* -// member (`true`, not `"true"`) — see README § Caveats for the limits. -type PatternKey = T extends boolean - ? T extends true - ? "true" - : "false" - : T extends null - ? "null" - : T extends undefined - ? "undefined" - : T; -type PatternParam = K extends "true" - ? true - : K extends "false" - ? false - : K extends "null" - ? null - : K extends "undefined" - ? undefined - : K; - type Handlers = { [K in PatternKey]: UnaryFn, R>; }; diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts index 2027f71..42bf03b 100644 --- a/src/tagged-union.test.ts +++ b/src/tagged-union.test.ts @@ -100,6 +100,130 @@ test("getTaggedUnionMatcher: dispatches on numeric tags", () => { assert.equal(pick({ kind: 2, b: 20 }), 20); }); +test("getTaggedUnionMatcher: dispatches on boolean tags", () => { + // Arrange — a boolean discriminant is not a property key, so the handler + // map is keyed `true`/`false` and the callback still receives the boolean. + interface Ok { + readonly status: true; + readonly value: number; + } + interface Err { + readonly status: false; + readonly message: string; + } + type Result = Ok | Err; + const factory = getTaggedUnionMatcher()("status"); + + // Act + const pick = factory({ + true: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.status, true); + return s.value; + }, + false: (s) => { + expectTypeOf(s).toEqualTypeOf(); + assert.equal(s.status, false); + return s.message.length; + }, + }); + + // Assert + expectTypeOf(pick).toEqualTypeOf<(shape: Result) => number>(); + assert.equal(pick({ status: true, value: 3 }), 3); + assert.equal(pick({ status: false, message: "hi" }), 2); +}); + +test("getTaggedUnionMatcher: dispatches on null and undefined tags", () => { + // Arrange + type Maybe = + | { readonly kind: null; readonly text: string } + | { readonly kind: undefined; readonly code: number }; + const factory = getTaggedUnionMatcher()("kind"); + + // Act + const pick = factory({ + null: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + readonly kind: null; + readonly text: string; + }>(); + assert.equal(s.kind, null); + return s.text.length; + }, + undefined: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + readonly kind: undefined; + readonly code: number; + }>(); + assert.equal(s.kind, undefined); + return s.code; + }, + }); + + // Assert + expectTypeOf(pick).toEqualTypeOf<(shape: Maybe) => number>(); + assert.equal(pick({ kind: null, text: "hi" }), 2); + assert.equal(pick({ kind: undefined, code: 7 }), 7); +}); + +test("getTaggedUnionMatcher: a fallback receives unhandled boolean and nullish members", () => { + // Arrange + type Mixed = + | { readonly kind: "a"; readonly a: number } + | { readonly kind: true; readonly b: number } + | { readonly kind: false; readonly c: number } + | { readonly kind: null; readonly d: number }; + const factory = getTaggedUnionMatcher()("kind"); + + // Act + const pick = factory({ a: () => 1 as const }, (s) => { + // The fallback sees the real members, not the projected keys. + expectTypeOf(s).toEqualTypeOf< + | { readonly kind: true; readonly b: number } + | { readonly kind: false; readonly c: number } + | { readonly kind: null; readonly d: number } + >(); + return 2 as const; + }); + + // Assert + expectTypeOf(pick).toEqualTypeOf<(shape: Mixed) => 1 | 2>(); + assert.equal(pick({ kind: "a", a: 1 }), 1); + assert.equal(pick({ kind: true, b: 1 }), 2); + assert.equal(pick({ kind: false, c: 1 }), 2); + assert.equal(pick({ kind: null, d: 1 }), 2); +}); + +test("getTaggedUnionMatcherW: boolean and nullish tags widen to the union", () => { + // Arrange + type Maybe = + | { readonly kind: true; readonly a: number } + | { readonly kind: false; readonly b: number } + | { readonly kind: null; readonly c: number }; + const factory = getTaggedUnionMatcherW()("kind"); + + // Act + const matcher = factory({ + true: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + readonly kind: true; + readonly a: number; + }>(); + assert.equal(s.kind, true); + return "yes" as const; + }, + false: () => 0 as const, + null: () => null, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: Maybe) => "yes" | 0 | null>(); + assert.equal(matcher({ kind: true, a: 1 }), "yes"); + assert.equal(matcher({ kind: false, b: 1 }), 0); + assert.equal(matcher({ kind: null, c: 1 }), null); +}); + // ============================================================================ // API: getTaggedUnionMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict // ============================================================================ @@ -260,6 +384,26 @@ test("getTaggedUnionMatcher factory rejects a non-discriminant key", () => { getTaggedUnionMatcher()("kind"); }); +test("getTaggedUnionMatcher factory rejects non-exhaustive boolean and nullish maps", () => { + // Arrange + type Result = + | { readonly status: true; readonly a: number } + | { readonly status: false; readonly b: number }; + type Maybe = + | { readonly status: null; readonly c: number } + | { readonly status: undefined; readonly d: number }; + const resultFactory = getTaggedUnionMatcher()("status"); + const maybeFactory = getTaggedUnionMatcher()("status"); + + // Act / Assert — the calls below must not compile + // @ts-expect-error only `true` is handled; `false` is not + resultFactory({ true: () => 1 }); + // @ts-expect-error only `null` is handled; `undefined` is not + maybeFactory({ null: () => 1 }); + // @ts-expect-error a fallback is redundant once both tags are handled + resultFactory({ true: () => 1, false: () => 2 }, () => 0); +}); + test("getTaggedUnionMatcherW factory rejects patterns outside its contract", () => { // Arrange const factory = getTaggedUnionMatcherW()("kind"); @@ -321,6 +465,8 @@ interface LabelsProbe { readonly factory: "getTaggedUnionMatcher" | "getTaggedUnionMatcherW"; readonly body: string; readonly tail?: string; + readonly typeName?: string; + readonly typeSource?: string; } const labelsFor = ({ @@ -328,14 +474,16 @@ const labelsFor = ({ factory, body, tail = "", + typeName = "Shape", + typeSource = SHAPE_SOURCE, }: 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")({`, + typeSource, + `const m = ${factory}<${typeName}>()("kind")({`, body, `}${tail});`, "", @@ -399,6 +547,29 @@ test("autocomplete: a fallback makes the remaining tags optional", () => { }); }); +// `true`/`false`/`null`/`undefined` tags are not property keys, so the popup +// must offer them by name through the `PatternKey` projection. +const STATUS_SOURCE = `type Status = { kind: true; a: number } | { kind: false; b: number } | { kind: null; c: number } | { kind: undefined; d: number };`; + +test("autocomplete: boolean and nullish tags are offered by name", () => { + // Arrange + const name = "tagged_union_boolean_nullish"; + + // Act + const labels = labelsFor({ + name, + factory: "getTaggedUnionMatcher", + typeName: "Status", + typeSource: STATUS_SOURCE, + body: " /*COMPLETE*/", + }); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["false", "null", "true", "undefined"]); + }); +}); + // `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 };`; diff --git a/src/tagged-union.ts b/src/tagged-union.ts index 16f6ab4..d092e65 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -2,15 +2,19 @@ import type { Exact, UnknownRecord } from "type-fest"; import type { HandlerMap, + PatternKey, + PatternParam, 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; +// `string` and `number` tags key a handler map directly; `boolean`, `null` and +// `undefined` are admitted too but are not property keys, so they go through the +// `PatternKey` projection. `symbol` has no literal syntax to write a handler +// under, and `bigint` is not a property key. +type Tag = string | number | boolean | null | undefined; // 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 @@ -25,23 +29,26 @@ type Discriminated = { // 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. +// Keyed by `PatternKey`, so `boolean`/`null`/`undefined` tags can key a mapped +// type; `PatternParam` inverts the projection to recover the member. type MapTaggedUnion = { - [V in Tags]: Extract>; + [P in PatternKey>]: Extract>>; }; type Handlers = { - [V in Tags]: UnaryFn[V], R>; + [P in PatternKey>]: UnaryFn[P], 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-union matcher's `Exclude>`. +// The members `Handled` covers. Mapping over the projected keys 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-union matcher's +// `Exclude>`. type HandledMembers = { - [V in Tags]: V extends keyof Handled - ? MapTaggedUnion[V] + [P in PatternKey>]: P extends keyof Handled + ? MapTaggedUnion[P] : never; -}[Tags]; +}[PatternKey>]; // 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. @@ -55,7 +62,7 @@ type Fallback = UnaryFn< // inference; see the primitive-union matcher for why a conditional in the fallback's // parameter is evaluated too early. type MustBePartial = - Tags extends keyof Handled ? RedundantFallback : unknown; + PatternKey> 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. @@ -97,8 +104,8 @@ const dispatch = (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. + // the factory admits only keys whose values are tags, and the map keys + // them by `PatternKey`, 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 ( From c06538d6c19cc46bbfec588856159e79e67f633e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 22 Sep 2026 10:23:06 +0000 Subject: [PATCH 2/8] :memo: Check off the tagged-union non-key tags task --- backlog.tasks | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backlog.tasks b/backlog.tasks index 0d80323..f53bfad 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -72,8 +72,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-union matcher's `PatternKey` / `PatternParam` projection; see development/library.md § Tagged-union matcher +✔ Allow boolean, null and undefined discriminant values in tagged-union patterns @medium @done + → moved the `PatternKey` / `PatternParam` projection to `matcher-shared.ts` and keyed the tagged-union handler map through it; see development/library.md § Tagged-union matcher Documentation: ☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place From f7b7a06f1ad39842e9ddaf669c8bc0576d94058e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 22 Sep 2026 10:33:26 +0000 Subject: [PATCH 3/8] :white_check_mark: Assert the fallback receives real boolean/nullish tags --- src/tagged-union.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts index 42bf03b..e3e1d26 100644 --- a/src/tagged-union.test.ts +++ b/src/tagged-union.test.ts @@ -184,6 +184,9 @@ test("getTaggedUnionMatcher: a fallback receives unhandled boolean and nullish m | { readonly kind: false; readonly c: number } | { readonly kind: null; readonly d: number } >(); + // `true`/`false`/`null` are keyed as `"true"`/`"false"`/`"null"`, + // but the fallback still receives the boolean/null, not the string. + assert.ok(s.kind === true || s.kind === false || s.kind === null); return 2 as const; }); From fcec637f01c89b3a5f3ee92e72fbbc1b2c4c091f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 22 Sep 2026 10:36:03 +0000 Subject: [PATCH 4/8] :white_check_mark: Check the runtime member in every boolean/nullish handler --- src/tagged-union.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts index e3e1d26..7873b81 100644 --- a/src/tagged-union.test.ts +++ b/src/tagged-union.test.ts @@ -216,8 +216,22 @@ test("getTaggedUnionMatcherW: boolean and nullish tags widen to the union", () = assert.equal(s.kind, true); return "yes" as const; }, - false: () => 0 as const, - null: () => null, + false: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + readonly kind: false; + readonly b: number; + }>(); + assert.equal(s.kind, false); + return 0 as const; + }, + null: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + readonly kind: null; + readonly c: number; + }>(); + assert.equal(s.kind, null); + return null; + }, }); // Assert From d88a4eeb4597debbf1ec694806fe8224afd9050f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 22 Sep 2026 10:45:54 +0000 Subject: [PATCH 5/8] :memo: Track optional discriminants under the union-property task --- backlog.tasks | 1 + 1 file changed, 1 insertion(+) diff --git a/backlog.tasks b/backlog.tasks index f53bfad..e457520 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -63,6 +63,7 @@ Matcher: ☐ when using a union type as a property, the current behavior of tagged union matcher is to pass never to handler parameters → new matcher function needed or can be fixed in tagged union matcher + ☐ optional discriminant (`{ type?: "x" }`) is the same hole: the boolean/nullish change now admits the `undefined` tag, so the factory accepts the key, but `Extract>` still passes `never` to both the `x` and `undefined` handlers Bugs: ✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done From 6228b4922d3f36ecd45f1a6ee71c85430b560906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 22 Sep 2026 10:54:27 +0000 Subject: [PATCH 6/8] :recycle: Share the Matchable universe between both matchers --- development/library.md | 14 +++++++++----- src/matcher-shared.ts | 6 ++++++ src/primitive-union.ts | 5 +---- src/tagged-union.ts | 16 ++++++++-------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/development/library.md b/development/library.md index 49ec1db..9196b90 100644 --- a/development/library.md +++ b/development/library.md @@ -97,9 +97,9 @@ Each factory is two overloads whose order is load-bearing: #### Decision (2026-09) -`src/matcher-shared.ts` holds the six universe-agnostic pieces both matchers -use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, and the -`PatternKey` / `PatternParam` key projection. +`src/matcher-shared.ts` holds the seven universe-agnostic pieces both matchers +use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared +`Matchable` universe, and the `PatternKey` / `PatternParam` key projection. #### Why @@ -107,6 +107,10 @@ use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, and the keeps the two matchers' message from drifting; the other pieces appear verbatim in both public signatures or are the same projection over each matcher's universe. +- **`Matchable` is one definition, not two.** The primitive-union matcher's + universe and the tagged-union matcher's allowed `Tag` values are the same set, + so aliasing them keeps the two matchers from drifting apart on what they + accept (`symbol`/`bigint` rejected once). #### Rejected @@ -114,8 +118,8 @@ use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, and the `Fallback` and `MustBePartial`.** Each is built from its own universe (`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the F-bounded `Handled` constraint that makes the remainder work risks the - contextual typing it exists to preserve. The `PatternKey` / `PatternParam` - projection is the one piece both universes genuinely share. + contextual typing it exists to preserve. `Matchable` and the `PatternKey` / + `PatternParam` projection are the pieces both universes genuinely share. ## Tagged-union matcher diff --git a/src/matcher-shared.ts b/src/matcher-shared.ts index 45dc574..9c302fc 100644 --- a/src/matcher-shared.ts +++ b/src/matcher-shared.ts @@ -8,6 +8,12 @@ import type { ValueOf } from "type-fest"; // A handler: one universe member in, one return value out. export type UnaryFn = (shape: T) => R; +// The primitive universe a matcher can discriminate. The primitive-union matcher +// uses it directly; the tagged-union matcher uses it as the set of allowed +// discriminant (`Tag`) values. `boolean` is admitted as the pair `true | false`; +// see README § Caveats for the unsupported members. +export type Matchable = string | number | boolean | null | undefined; + // `boolean`, `null` and `undefined` cannot be property keys, so a mapped type // over a universe that includes one keys each such member by its // stringification. `PatternParam` inverts that projection, so a handler callback diff --git a/src/primitive-union.ts b/src/primitive-union.ts index 76b58d8..bebc727 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -2,6 +2,7 @@ import type { Exact } from "type-fest"; import type { HandlerMap, + Matchable, PatternKey, PatternParam, PatternReturns, @@ -9,10 +10,6 @@ import type { 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. -type Matchable = string | number | boolean | null | undefined; - type Handlers = { [K in PatternKey]: UnaryFn, R>; }; diff --git a/src/tagged-union.ts b/src/tagged-union.ts index d092e65..26b0a00 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -2,6 +2,7 @@ import type { Exact, UnknownRecord } from "type-fest"; import type { HandlerMap, + Matchable, PatternKey, PatternParam, PatternReturns, @@ -9,22 +10,21 @@ import type { UnaryFn, } from "./matcher-shared.ts"; -// A tagged union is discriminated by one property whose values are the tags. -// `string` and `number` tags key a handler map directly; `boolean`, `null` and -// `undefined` are admitted too but are not property keys, so they go through the -// `PatternKey` projection. `symbol` has no literal syntax to write a handler -// under, and `bigint` is not a property key. -type Tag = string | number | boolean | null | undefined; +// A tagged union is discriminated by one property whose values are the tags (a +// `Matchable`). `string` and `number` tags key a handler map directly; +// `boolean`, `null` and `undefined` are admitted too but are not property keys, +// so they go through the `PatternKey` projection. `symbol` has no literal syntax +// to write a handler under, and `bigint` is not a property key. // 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; +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; + [K in keyof T]: T[K] extends Matchable ? K : never; }[keyof T]; // The member(s) of `T` tagged `V`. `Extract` distributes over the union, so a From 09e1c122700940913f62a0f6116555608adab938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 22 Sep 2026 10:57:03 +0000 Subject: [PATCH 7/8] :recycle: Return dispatch tacitly from the tagged-union factory --- src/tagged-union.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/tagged-union.ts b/src/tagged-union.ts index 26b0a00..69aa65b 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -99,6 +99,17 @@ interface TaggedUnionMatcherWidening { ): UnaryFn | R>; } +// The key-taking step of the curried factory. Naming it lets the factory return +// `dispatch` directly, the tacit twin of the primitive-union factory's bare +// `=> dispatch`. +interface TaggedUnionMatcherFactory { + >(k: K): TaggedUnionMatcherStrict; +} + +interface TaggedUnionMatcherWideningFactory { + >(k: K): TaggedUnionMatcherWidening; +} + const dispatch = (k: PropertyKey) => (handlers: HandlerMap, fallback?: UnaryFn) => @@ -120,12 +131,10 @@ const dispatch = ); }; -export const getTaggedUnionMatcher = - () => - >(k: K): TaggedUnionMatcherStrict => - dispatch(k); +export const getTaggedUnionMatcher = < + T extends object, +>(): TaggedUnionMatcherFactory => dispatch; -export const getTaggedUnionMatcherW = - () => - >(k: K): TaggedUnionMatcherWidening => - dispatch(k); +export const getTaggedUnionMatcherW = < + T extends object, +>(): TaggedUnionMatcherWideningFactory => dispatch; From 4ebe18adb159bed6ccea74039b9926c7754f0907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 22 Sep 2026 11:05:16 +0000 Subject: [PATCH 8/8] :recycle: Align primitive-union matcher names and drop stale lint disables Rename `MatcherStrict` / `MatcherWidening` to `PrimitiveUnionMatcherStrict` / `PrimitiveUnionMatcherWidening`, matching the exported `getPrimitiveUnionMatcher(W)` names and the `TaggedUnionMatcher*` pair. Folds in the working-tree lint cleanups: drop the now-unneeded `typescript/unified-signatures` disables and turn the tagged-union factory interfaces into type aliases. --- src/primitive-union.ts | 12 ++++-------- src/tagged-union.ts | 14 ++++++++------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/primitive-union.ts b/src/primitive-union.ts index bebc727..7c6061b 100644 --- a/src/primitive-union.ts +++ b/src/primitive-union.ts @@ -40,11 +40,10 @@ type MustBePartial = // 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 Handlers (first) -> the exhaustive form and the autocomplete popup // #2 Fallback (last) -> accepts a partial handler map plus a fallback -interface MatcherStrict { +interface PrimitiveUnionMatcherStrict { (handlers: Handlers): UnaryFn; < R, @@ -55,13 +54,11 @@ interface MatcherStrict { fallback: Fallback, ): UnaryFn; } -// oxlint-enable typescript/unified-signatures // 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. -// oxlint-disable typescript/unified-signatures -interface MatcherWidening { +interface PrimitiveUnionMatcherWidening {

, P>>( handlers: P, ): UnaryFn>; @@ -74,7 +71,6 @@ interface MatcherWidening { fallback: Fallback, ): UnaryFn | R>; } -// oxlint-enable typescript/unified-signatures const dispatch = (handlers: HandlerMap, fallback?: UnaryFn) => @@ -101,7 +97,7 @@ const dispatch = export const getPrimitiveUnionMatcher = < T extends Matchable, ->(): MatcherStrict => dispatch; +>(): PrimitiveUnionMatcherStrict => dispatch; export const getPrimitiveUnionMatcherW = < T extends Matchable, ->(): MatcherWidening => dispatch; +>(): PrimitiveUnionMatcherWidening => dispatch; diff --git a/src/tagged-union.ts b/src/tagged-union.ts index 69aa65b..f501cc3 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -102,13 +102,15 @@ interface TaggedUnionMatcherWidening { // The key-taking step of the curried factory. Naming it lets the factory return // `dispatch` directly, the tacit twin of the primitive-union factory's bare // `=> dispatch`. -interface TaggedUnionMatcherFactory { - >(k: K): TaggedUnionMatcherStrict; -} +type TaggedUnionMatcherFactory = >( + k: K, +) => TaggedUnionMatcherStrict; -interface TaggedUnionMatcherWideningFactory { - >(k: K): TaggedUnionMatcherWidening; -} +type TaggedUnionMatcherWideningFactory = < + K extends Discriminated, +>( + k: K, +) => TaggedUnionMatcherWidening; const dispatch = (k: PropertyKey) =>