🐛 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
|
||||
// ============================================================================
|
||||
|
||||
Reference in new issue
Block a user