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 1/3] :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 >; From 1bdf7f0315eeaf2dc06b1cc3bf064e4c423cff9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 16:32:09 +0000 Subject: [PATCH 2/3] :bug: Reject mixed literal and broad universes `UnsupportedReason` tested `IsLiteral> extends false`. `IsLiteral` is `boolean` for a union that mixes a literal with a broad type (`"a" | \`x-${number}\``), so `boolean extends false` is false and the mix was accepted. Test `extends true` instead, which rejects it. Found while adding property-union broad-universe coverage; the gate is shared with the primitive-union matcher. --- CHANGELOG.md | 2 ++ development/library.md | 4 ++++ src/matcher-shared.ts | 10 +++++----- src/primitive-union.test.ts | 2 ++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b14b7c..7c95858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- reject a universe that mixes a literal with a broad type (e.g. + `"a" | \`x-${number}\``), which the finite-literal gate let through - 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` diff --git a/development/library.md b/development/library.md index 51bcc04..3254d4a 100644 --- a/development/library.md +++ b/development/library.md @@ -145,6 +145,10 @@ inversion: the handler parameter is the member(s) of `T` whose `PatternKey` is object satisfy the exhaustive overload and reaches the `dispatch` throw. Rejecting at the boundary avoids threading an open/closed branch through `Handlers`, `Fallback` and `MustBePartial`. +- **The finite-literal predicate is `IsLiteral> extends true`.** + `IsLiteral` is `boolean` for a union that mixes a literal with a broad type + (`"a" | \`x-${number}\``), so `extends false`would treat the mix as +supported;`extends true` is the check that rejects it. - **Collisions are rejected, not merged.** `Member` would be sound (the handler gets the union), but the API is one handler per member; rejecting keeps `Member` a singleton and the remainder exact. diff --git a/src/matcher-shared.ts b/src/matcher-shared.ts index fead4cc..ecb1732 100644 --- a/src/matcher-shared.ts +++ b/src/matcher-shared.ts @@ -62,11 +62,11 @@ export type Collisions = Extract>; // universe is a finite union of literals with no value/stringification // collision: only then can `PatternKey` be inverted unambiguously. export type UnsupportedReason = - IsLiteral> extends false - ? "broad types like string, number and template literals are not supported" - : [Collisions] extends [never] - ? never - : `a value and its stringification collide. Value is "${Collisions & string}"`; + IsLiteral> extends true + ? [Collisions] extends [never] + ? never + : `a value and its stringification collide. Value is "${Collisions & string}"` + : "broad types like string, number and template literals are not supported"; // The diagnostic for an unsupported universe. Extends `HandlerMap` so the // implementation's `handlers: HandlerMap` stays assignable when the gate is diff --git a/src/primitive-union.test.ts b/src/primitive-union.test.ts index 17c2ac0..bd307d4 100644 --- a/src/primitive-union.test.ts +++ b/src/primitive-union.test.ts @@ -528,6 +528,8 @@ test("getPrimitiveUnionMatcher: a broad universe is rejected", () => { getPrimitiveUnionMatcher()({ 1: () => 1 }); // @ts-expect-error a template literal is open getPrimitiveUnionMatcher<`a${string}`>()({ a: () => 1 }); + // @ts-expect-error a literal mixed with a template literal is open + getPrimitiveUnionMatcher<"a" | `x-${number}`>()({ a: () => 1 }); // @ts-expect-error `string | boolean` is open because of `string` getPrimitiveUnionMatcher()({ true: () => 1 }); // @ts-expect-error the widening factory rejects broad universes too From 7b90f898d3b01f0a939359447b8b2b64b6f42eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 16:32:16 +0000 Subject: [PATCH 3/3] :white_check_mark: Cover property-union autocomplete and rejection Add property-union tests for the completion popup, a broad value in the union, and value/stringification collisions. Rename the existing property-union tests to a `property union:` prefix so they can be selected with `--test-name-pattern`, and extend the autocomplete helper with a `key` option so a probe can complete on a property other than `kind`. --- src/tagged-union.test.ts | 158 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 147 insertions(+), 11 deletions(-) diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts index 36a3564..bd6f4b1 100644 --- a/src/tagged-union.test.ts +++ b/src/tagged-union.test.ts @@ -893,10 +893,10 @@ test("getTaggedUnionMatcher: the maximum illegal tag universe is rejected", () = }); // ============================================================================ -// Union-valued and optional discriminants +// Property unions // ============================================================================ -test("getTaggedUnionMatcher: a union-valued property narrows per handler", () => { +test("property union: narrows each handler", () => { // Arrange — one shape, not a union of variants; `color` is a union. interface Paint { readonly color: "red" | "green" | "blue"; @@ -939,7 +939,7 @@ test("getTaggedUnionMatcher: a union-valued property narrows per handler", () => assert.equal(describe({ color: "blue", value: 3 }), 3); }); -test("getTaggedUnionMatcher: a fallback narrows a union-valued property", () => { +test("property union: a fallback receives the unhandled values", () => { // Arrange interface Paint { color: "red" | "green" | "blue"; @@ -976,7 +976,7 @@ test("getTaggedUnionMatcher: a fallback narrows a union-valued property", () => assert.equal(describe({ color: "blue", value: 3 }), 2); }); -test("getTaggedUnionMatcher: a numeric union-valued property narrows per handler", () => { +test("property union: a numeric property narrows each handler", () => { // Arrange interface Version { code: 1 | 2 | 3; @@ -1010,7 +1010,7 @@ test("getTaggedUnionMatcher: a numeric union-valued property narrows per handler assert.equal(describe({ code: 3, value: 30 }), 3); }); -test("getTaggedUnionMatcher: an optional discriminant narrows both handlers", () => { +test("property union: an optional property is required for a defined tag", () => { // Arrange — `type?` is `"x" | undefined`; both handlers must be typed, // and matching `"x"` proves the key is present, so `type` becomes required. interface Maybe { @@ -1041,7 +1041,7 @@ test("getTaggedUnionMatcher: an optional discriminant narrows both handlers", () assert.equal(describe({ value: 2 }), 2); }); -test("getTaggedUnionMatcher: an optional discriminant fallback keeps the undefined tag", () => { +test("property union: an optional fallback receives the undefined tag", () => { // Arrange interface Maybe { type?: "x"; @@ -1074,7 +1074,7 @@ test("getTaggedUnionMatcher: an optional discriminant fallback keeps the undefin assert.equal(describe({ value: 2 }), 2); }); -test("getTaggedUnionMatcher: explicit `undefined` stays distinct from absence", () => { +test("property union: 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 { @@ -1107,7 +1107,7 @@ test("getTaggedUnionMatcher: explicit `undefined` stays distinct from absence", assert.equal(describe({ value: 3 }), 2); }); -test("getTaggedUnionMatcher: a union-valued member is split, not dropped", () => { +test("property union: 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 @@ -1139,7 +1139,7 @@ test("getTaggedUnionMatcher: a union-valued member is split, not dropped", () => assert.equal(describe({ kind: "b", b: 3 }), 2); }); -test("getTaggedUnionMatcherW: a union-valued property widens to the union of returns", () => { +test("property union: getTaggedUnionMatcherW widens the returns", () => { // Arrange interface Paint { color: "red" | "green" | "blue"; @@ -1173,7 +1173,7 @@ test("getTaggedUnionMatcherW: a union-valued property widens to the union of ret assert.equal(describe({ color: "blue", value: 3 }), true); }); -test("getTaggedUnionMatcher: a union-valued property still enforces its contract", () => { +test("property union: enforces its contract", () => { // Arrange interface Paint { color: "red" | "green" | "blue"; @@ -1195,6 +1195,55 @@ test("getTaggedUnionMatcher: a union-valued property still enforces its contract factory({ red: () => 1, green: () => 2, blue: () => 3 }, () => 0); }); +test("property union: a broad value in the union is rejected", () => { + // Arrange — the property is a union, but not a finite union of literals: a + // template literal cannot be proven exhaustive. + interface Template { + kind: "a" | `x-${number}`; + a: number; + } + + // Act / Assert — the calls below must not compile + // @ts-expect-error a template literal is not a finite literal + getTaggedUnionMatcher