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] :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 (