From 55bf6c24a728f29535ebed879ab69f425dce3ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 16:04:00 +0000 Subject: [PATCH] :bug: Narrow union-valued and optional discriminants The tagged-union matcher selected handler parameters with `Extract>`, which only keeps a member whose discriminant is a singleton literal. A property that is itself a union (`{ color: "red" | "green" | "blue" }`) or optional (`{ type?: "x" }`) matched no member, so every handler received `never`, and the fallback saw the whole shape instead of the unhandled tags. Replace the selector with `Narrowed`, which distributes over `T`, drops a member whose `K` cannot take the tag, and narrows `K`. The `T[K] extends V` fast path keeps an exact member (a discriminated union's declared interface) untouched. The fallback reuses `Narrowed` over `Exclude`, so a union-valued property narrows to the unhandled tags rather than the whole shape. Matching a defined tag on an optional property makes the key required (`{ type: "x" }`); the `undefined` tag yields `{ type?: never }` under `exactOptionalPropertyTypes` (absence) or `{ type?: undefined }` when the property admits an explicit `undefined`. --- CHANGELOG.md | 3 + backlog.tasks | 4 +- development/library.md | 23 ++- src/tagged-union.test.ts | 303 +++++++++++++++++++++++++++++++++++++++ src/tagged-union.ts | 49 ++++--- 5 files changed, 357 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7591b10..8b14b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- narrow the tagged-union matcher's handler parameters for a union-valued or + optional discriminant, and its fallback to the unhandled tags, instead of + passing `never` - prune the backlog of completed items and record the TS 7 LSP shutdown workaround in development/testing.md diff --git a/backlog.tasks b/backlog.tasks index d4e1650..33d38a4 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -17,10 +17,10 @@ v1.0: ☐ Achieve 100% branch coverage on `src/index.ts` Matcher: -☐ when using a union type as a property, the current behavior of tagged union matcher is +✔ when using a union type as a property, the current behavior of tagged union matcher is @done 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 + ✔ 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 @done 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 b7b6869..51bcc04 100644 --- a/development/library.md +++ b/development/library.md @@ -208,16 +208,27 @@ The key is a separate call because `K` is inferred from its literal argument and #### Why -- **Same fallback/remainder machinery as the primitive-union 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. +- **Same fallback/remainder machinery as the primitive-union matcher.** `HandledTags` + recovers the tag values the map handled (`Member`) and + `Narrowed>` 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-union dispatch's `shape as string | number`. -- **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a - union of members instead of dropping one. +- **`Narrowed` distributes over `T`, narrowing `K` to the tag.** A member whose + `K` cannot take the tag drops out; a duplicated tag yields a union of members + instead of dropping one. `T[K] extends V` returns the exact member (a + discriminated union's declared interface) untouched, so the mapped form only + handles a property that is itself a union. +- **A union-valued or optional discriminant is supported.** A single shape whose + property is a union (`{ color: "red" | "green" | "blue" }`) is narrowed per + handler instead of being passed `never`. Matching a defined tag on an optional + property (`{ type?: "x" }`) proves the key is present, so it becomes required + (`{ type: "x" }`); the `undefined` tag narrows it to `{ type?: never }` under + `exactOptionalPropertyTypes` (absence) or `{ type?: undefined }` when the + property explicitly admits `undefined`. - **A `boolean` / `null` / `undefined` tag goes through the shared `PatternKey` / `Member` projection.** `Discriminated` admits those tags (they are in `Tag`), but they cannot key a mapped type, so the handler map is diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts index cadb6ed..36a3564 100644 --- a/src/tagged-union.test.ts +++ b/src/tagged-union.test.ts @@ -892,6 +892,309 @@ test("getTaggedUnionMatcher: the maximum illegal tag universe is rejected", () = }); }); +// ============================================================================ +// Union-valued and optional discriminants +// ============================================================================ + +test("getTaggedUnionMatcher: a union-valued property narrows per handler", () => { + // Arrange — one shape, not a union of variants; `color` is a union. + interface Paint { + readonly color: "red" | "green" | "blue"; + readonly value: number; + } + const factory = getTaggedUnionMatcher()("color"); + + // Act + const describe = factory({ + red: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + readonly color: "red"; + readonly value: number; + }>(); + assert.equal(s.color, "red"); + return s.value; + }, + green: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + readonly color: "green"; + readonly value: number; + }>(); + assert.equal(s.color, "green"); + return s.value; + }, + blue: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + readonly color: "blue"; + readonly value: number; + }>(); + assert.equal(s.color, "blue"); + return s.value; + }, + }); + + // Assert + expectTypeOf(describe).toEqualTypeOf<(shape: Paint) => number>(); + assert.equal(describe({ color: "red", value: 1 }), 1); + assert.equal(describe({ color: "green", value: 2 }), 2); + assert.equal(describe({ color: "blue", value: 3 }), 3); +}); + +test("getTaggedUnionMatcher: a fallback narrows a union-valued property", () => { + // Arrange + interface Paint { + color: "red" | "green" | "blue"; + value: number; + } + const factory = getTaggedUnionMatcher()("color"); + + // Act + const describe = factory( + { + red: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + color: "red"; + value: number; + }>(); + assert.equal(s.color, "red"); + return 1 as const; + }, + }, + (s) => { + expectTypeOf(s).toEqualTypeOf<{ + color: "green" | "blue"; + value: number; + }>(); + assert.ok(s.color === "green" || s.color === "blue"); + return 2 as const; + }, + ); + + // Assert + expectTypeOf(describe).toEqualTypeOf<(shape: Paint) => 1 | 2>(); + assert.equal(describe({ color: "red", value: 1 }), 1); + assert.equal(describe({ color: "green", value: 2 }), 2); + assert.equal(describe({ color: "blue", value: 3 }), 2); +}); + +test("getTaggedUnionMatcher: a numeric union-valued property narrows per handler", () => { + // Arrange + interface Version { + code: 1 | 2 | 3; + value: number; + } + const factory = getTaggedUnionMatcher()("code"); + + // Act + const describe = factory({ + 1: (s) => { + expectTypeOf(s).toEqualTypeOf<{ code: 1; value: number }>(); + assert.equal(s.code, 1); + return 1; + }, + 2: (s) => { + expectTypeOf(s).toEqualTypeOf<{ code: 2; value: number }>(); + assert.equal(s.code, 2); + return 2; + }, + 3: (s) => { + expectTypeOf(s).toEqualTypeOf<{ code: 3; value: number }>(); + assert.equal(s.code, 3); + return 3; + }, + }); + + // Assert + expectTypeOf(describe).toEqualTypeOf<(shape: Version) => number>(); + assert.equal(describe({ code: 1, value: 10 }), 1); + assert.equal(describe({ code: 2, value: 20 }), 2); + assert.equal(describe({ code: 3, value: 30 }), 3); +}); + +test("getTaggedUnionMatcher: an optional discriminant narrows both handlers", () => { + // Arrange — `type?` is `"x" | undefined`; both handlers must be typed, + // and matching `"x"` proves the key is present, so `type` becomes required. + interface Maybe { + type?: "x"; + value: number; + } + const factory = getTaggedUnionMatcher()("type"); + + // Act + const describe = factory({ + x: (s) => { + expectTypeOf(s).toEqualTypeOf<{ type: "x"; value: number }>(); + assert.equal(s.type, "x"); + return 1; + }, + undefined: (s) => { + // The input only allows absence (exactOptionalPropertyTypes), so + // the `undefined` tag narrows the key to never-present. + expectTypeOf(s).toEqualTypeOf<{ type?: never; value: number }>(); + assert.equal(s.type, undefined); + return 2; + }, + }); + + // Assert + expectTypeOf(describe).toEqualTypeOf<(shape: Maybe) => number>(); + assert.equal(describe({ type: "x", value: 1 }), 1); + assert.equal(describe({ value: 2 }), 2); +}); + +test("getTaggedUnionMatcher: an optional discriminant fallback keeps the undefined tag", () => { + // Arrange + interface Maybe { + type?: "x"; + value: number; + } + const factory = getTaggedUnionMatcher()("type"); + + // Act + const describe = factory( + { + x: (s) => { + expectTypeOf(s).toEqualTypeOf<{ type: "x"; value: number }>(); + assert.equal(s.type, "x"); + return 1 as const; + }, + }, + (s) => { + expectTypeOf(s).toEqualTypeOf<{ + type?: never; + value: number; + }>(); + assert.equal(s.type, undefined); + return 2 as const; + }, + ); + + // Assert + expectTypeOf(describe).toEqualTypeOf<(shape: Maybe) => 1 | 2>(); + assert.equal(describe({ type: "x", value: 1 }), 1); + assert.equal(describe({ value: 2 }), 2); +}); + +test("getTaggedUnionMatcher: explicit `undefined` stays distinct from absence", () => { + // Arrange — `type?: "x" | undefined` admits an explicit `undefined`, unlike + // the bare optional above; the two representations must not be conflated. + interface Explicit { + type?: "x" | undefined; + value: number; + } + const factory = getTaggedUnionMatcher()("type"); + + // Act + const describe = factory({ + x: (s) => { + expectTypeOf(s).toEqualTypeOf<{ type: "x"; value: number }>(); + assert.equal(s.type, "x"); + return 1; + }, + undefined: (s) => { + expectTypeOf(s).toEqualTypeOf<{ + type?: undefined; + value: number; + }>(); + assert.equal(s.type, undefined); + return 2; + }, + }); + + // Assert + expectTypeOf(describe).toEqualTypeOf<(shape: Explicit) => number>(); + assert.equal(describe({ type: "x", value: 1 }), 1); + assert.equal(describe({ type: undefined, value: 2 }), 2); + assert.equal(describe({ value: 3 }), 2); +}); + +test("getTaggedUnionMatcher: a union-valued member is split, not dropped", () => { + // A member whose tag is itself a union sits beside a singleton-tag member. + // Selecting members with `Extract>` keeps only the + // singleton; the distributive narrowing must keep both sides of the + // union-valued member. + // Arrange + type Mixed = { kind: "a"; a: number } | { kind: "a" | "b"; b: number }; + const factory = getTaggedUnionMatcher()("kind"); + + // Act + const describe = factory({ + a: (s) => { + expectTypeOf(s).toEqualTypeOf< + { kind: "a"; a: number } | { kind: "a"; b: number } + >(); + assert.equal(s.kind, "a"); + return 1; + }, + b: (s) => { + expectTypeOf(s).toEqualTypeOf<{ kind: "b"; b: number }>(); + assert.equal(s.kind, "b"); + return 2; + }, + }); + + // Assert + expectTypeOf(describe).toEqualTypeOf<(shape: Mixed) => number>(); + assert.equal(describe({ kind: "a", a: 1 }), 1); + assert.equal(describe({ kind: "a", b: 2 }), 1); + assert.equal(describe({ kind: "b", b: 3 }), 2); +}); + +test("getTaggedUnionMatcherW: a union-valued property widens to the union of returns", () => { + // Arrange + interface Paint { + color: "red" | "green" | "blue"; + value: number; + } + const factory = getTaggedUnionMatcherW()("color"); + + // Act + const describe = factory({ + red: (s) => { + expectTypeOf(s).toEqualTypeOf<{ color: "red"; value: number }>(); + assert.equal(s.color, "red"); + return "r" as const; + }, + green: (s) => { + expectTypeOf(s).toEqualTypeOf<{ color: "green"; value: number }>(); + assert.equal(s.color, "green"); + return 1 as const; + }, + blue: (s) => { + expectTypeOf(s).toEqualTypeOf<{ color: "blue"; value: number }>(); + assert.equal(s.color, "blue"); + return true as const; + }, + }); + + // Assert + expectTypeOf(describe).toEqualTypeOf<(shape: Paint) => "r" | 1 | true>(); + assert.equal(describe({ color: "red", value: 1 }), "r"); + assert.equal(describe({ color: "green", value: 2 }), 1); + assert.equal(describe({ color: "blue", value: 3 }), true); +}); + +test("getTaggedUnionMatcher: a union-valued property still enforces its contract", () => { + // Arrange + interface Paint { + color: "red" | "green" | "blue"; + value: number; + } + const factory = getTaggedUnionMatcher()("color"); + + // Act / Assert — the calls below must not compile + factory({ + red: () => 1, + green: () => 2, + blue: () => 3, + // @ts-expect-error `yellow` is not a value of `color` + yellow: () => 4, + }); + // @ts-expect-error a gap without a fallback is not exhaustive + factory({ red: () => 1 }); + // @ts-expect-error a fallback is redundant once every value is handled + factory({ red: () => 1, green: () => 2, blue: () => 3 }, () => 0); +}); + // ============================================================================ // Dispatch — runtime behavior // ============================================================================ diff --git a/src/tagged-union.ts b/src/tagged-union.ts index fabfb07..b43e9ad 100644 --- a/src/tagged-union.ts +++ b/src/tagged-union.ts @@ -1,4 +1,4 @@ -import type { Exact, UnknownRecord } from "type-fest"; +import type { Exact, SetRequired, UnknownRecord } from "type-fest"; import type { HandlerMap, @@ -28,34 +28,49 @@ type Discriminated = { [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 +// The member(s) of `T` narrowed to the tag(s) `V`. Distributes over `T`, so a // duplicated tag maps to a union of members rather than silently dropping one. +// A member whose `K` cannot take `V` drops out; the rest have `K` narrowed to +// `Extract`. `T[K] extends V` keeps an exact member (a discriminated +// union's declared interface) untouched, so only a property that is itself a +// union — or optional — goes through the mapped form. `SetRequired` makes `K` +// required unless `V` admits `undefined`: a defined tag yields `{ type: "x" }`, +// while the `undefined` tag keeps `{ type?: never }` (absence) or +// `{ type?: undefined }` when the property admits an explicit `undefined`. // Keyed by `PatternKey`, so `boolean`/`null`/`undefined` tags can key a mapped -// type; `Member` inverts the projection against the tag set to recover the -// member. +// type; `Member` inverts the projection against the tag set to recover the tag. +type Narrowed = T extends object + ? [Extract] extends [never] + ? never + : T[K] extends V + ? T + : undefined extends V + ? { [P in keyof T]: P extends K ? Extract : T[P] } + : SetRequired< + { [P in keyof T]: P extends K ? Extract : T[P] }, + K + > + : never; + type MapTaggedUnion = { - [P in PatternKey>]: Extract, P>>>; + [P in PatternKey>]: Narrowed, P>>; }; type Handlers = { [P in PatternKey>]: UnaryFn[P], R>; }; -// 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 = { - [P in PatternKey>]: P extends keyof Handled - ? MapTaggedUnion[P] - : never; -}[PatternKey>]; +// The tag values whose `PatternKey` is handled. +type HandledTags = Member< + Tags, + keyof Handled +>; // The fallback is a *second argument*, not a property of the handler map, so -// its parameter can be the remainder the map left uncovered. See development/library.md. +// its parameter can be the remainder the map left uncovered: the members +// narrowed to the tags the map did not handle. See development/library.md. type Fallback = UnaryFn< - Exclude>, + Narrowed, HandledTags>>, R >;