Unions with overlapping discriminator property do not perform internal excess property checks
See original GitHub issueBug Report
Discriminating unions provide a means for type narrowing based on a discriminator property. The first property common to all union constituents is taken as the discriminator property.
If object literals provide a discriminator value, their expected shape gets narrowed to the corresponding union constituent. Excess properties from other constituents (or completely unrelated properties) raise an excess property check (EPC) error.
At present this only works if there is a single matching candidate constituent for the object literal being tested. If two or more types within the union match the provided discriminator value, EPC accepts properties from the entire union - even though only a subset actually matched.
EPC should continue to work with the subset of matching constituents, not the entire union.
The use-case I have in mind for overlapping discriminators is to specify optional properties that may (only) be present in the object literal for specific discriminator values.
interface Common {
type: "A" | "B" | "C" | "D" | [...many];
n: number;
}
interface A {
type: "A";
a?: number;
}
interface B {
type: "B";
b?: number;
}
type CommonWithOptionals = Common | (Common & A) | (Common & B);
Because of the above issue, { type: "a", n: 1, a: 1, b: 1 }
does not raise an EPC error, even though it is clearly invalid.
The alternative is to specify something along the lines of (Common & { type: "C" | "D" | [...many]> })
(?), or the StrictUnion<> approach, but this is quite cumbersome. It may also be the case that Common
is a class rather than an interface, or is otherwise an important type of its own, so subtracting from it may not be suitable.
It is worth noting that if (obj.type === "A") obj.a
still throws TS(2339): Property 'a' does not exist on type 'Common | (Common & A)'
. While this might seem to defeat the purpose of a discriminating union, the goal is merely to ensure correct EPC. We are still explicitly accepting both types, independently of each other. Type guards and in
checks can then be used to access A
as intended.
🔎 Search Terms
discriminator union overlap excess property check
Similar but subtly different to #20863.
🕗 Version Information
v5.0-next v4.9.4
- This is the behavior in every version I tried, and I reviewed the FAQ for entries about unions and discriminators.
⏯ Playground Link
Playground link with relevant code
💻 Code
interface Parent {
type: "foo" | "bar" | "baz";
}
interface Bar {
type: "bar";
bar?: number;
}
interface Baz {
type: "baz";
baz?: number;
}
type Union = Parent | Bar | Baz;
function test(union: Union) {}
test({
type: "foo",
bar: 1 // ✅ TS(2345): Object literal may only specify known properties, and 'bar' does not exist in type 'Parent'.
});
test({
type: "bar",
bar: 1,
baz: 1 // ❌ No error?
});
🙁 Actual behavior
EPC error raised for single matching discriminated type (Parent
), no error raised for combined matching types (Parent | Bar
).
🙂 Expected behavior
EPC error raised in both cases, because { baz: number }
should only be present for Baz
interface, which would have required {type: "baz" }
.
Issue Analytics
- State:
- Created 9 months ago
- Reactions:1
- Comments:7 (2 by maintainers)
Top GitHub Comments
I think this is a well-thought-out proposal; the comments in those testcases I believe predate the notion of fishing out a discriminated target type. Can you put up a PR that includes a testcase of the OP and we can evaluate the perf/correctness impact?
Potential fix: https://github.com/nebkat/TypeScript/commit/f93cc3457f38d1ecb97dba9e483341761dce94cb
Appears to have minimal side effects, but I’m not sure if original behavior is due to a performance constraint.
Will add further tests if some there is some consensus on the issue.