🐛 Narrow union-valued and optional discriminants

The tagged-union matcher selected handler parameters with
`Extract<T, Record<K, V>>`, 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<Tags, HandledTags>`, 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`.
This commit is contained in:
tmu committed 2026-09-23 16:04:00 +00:00
1 parent e7b5c0284d
commit 55bf6c24a7
5 files changed
+357 -25

No files matched your search

+3
View File
@@ -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
+2 -2
View File
@@ -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<T, Record<K, V>>` 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<T, Record<K, V>>` 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
+17 -6
View File
@@ -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<T, …>` 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<Tags, keyof Handled>`) and
`Narrowed<T, K, Exclude<Tags, …>>` 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<PropertyKey, unknown>`.** 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
+303
View File
@@ -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<Paint>()("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<Paint>()("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<Version>()("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<Maybe>()("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<Maybe>()("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<Explicit>()("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<T, Record<K, V>>` 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<Mixed>()("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<Paint>()("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<Paint>()("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
// ============================================================================
+32 -17
View File
@@ -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<T extends object> = {
[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], V>`. `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, K extends keyof T, V> = T extends object
? [Extract<T[K], V>] extends [never]
? never
: T[K] extends V
? T
: undefined extends V
? { [P in keyof T]: P extends K ? Extract<T[P], V> : T[P] }
: SetRequired<
{ [P in keyof T]: P extends K ? Extract<T[P], V> : T[P] },
K
>
: never;
type MapTaggedUnion<T extends object, K extends keyof T> = {
[P in PatternKey<Tags<T, K>>]: Extract<T, Record<K, Member<Tags<T, K>, P>>>;
[P in PatternKey<Tags<T, K>>]: Narrowed<T, K, Member<Tags<T, K>, P>>;
};
type Handlers<T extends object, K extends keyof T, R> = {
[P in PatternKey<Tags<T, K>>]: UnaryFn<MapTaggedUnion<T, K>[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<T, …>`, mirroring the primitive-union matcher's
// `Exclude<T, Member<T, keyof Handled>>`.
type HandledMembers<T extends object, K extends keyof T, Handled> = {
[P in PatternKey<Tags<T, K>>]: P extends keyof Handled
? MapTaggedUnion<T, K>[P]
: never;
}[PatternKey<Tags<T, K>>];
// The tag values whose `PatternKey` is handled.
type HandledTags<T extends object, K extends keyof T, Handled> = Member<
Tags<T, K>,
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<T extends object, K extends keyof T, Handled, R> = UnaryFn<
Exclude<T, HandledMembers<T, K, Handled>>,
Narrowed<T, K, Exclude<Tags<T, K>, HandledTags<T, K, Handled>>>,
R
>;