🐛 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:
1 parent
e7b5c0284d
commit
55bf6c24a7
5 files changed
+357
-25
No files matched your search
@@ -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
@@ -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
|
||||
>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user