question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Unions with overlapping discriminator property do not perform internal excess property checks

See original GitHub issue

Bug 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:open
  • Created 9 months ago
  • Reactions:1
  • Comments:7 (2 by maintainers)

github_iconTop GitHub Comments

4reactions
RyanCavanaughcommented, Dec 13, 2022

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?

1reaction
nebkatcommented, Dec 13, 2022

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.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Type union not checking for excess properties - Stack Overflow
And the quirk of excess property checks is that for unions, it allows any property from any union constituent to be present in...
Read more >
Handbook - Unions and Intersection Types - TypeScript
How to use unions and intersection types in TypeScript. ... Property 'swim' does not exist on type 'Bird | Fish'. Property 'swim' does...
Read more >
Fair Lending Laws and Regulations - FDIC
There is overt evidence of discrimination even when a lender expresses — but does not act on — a discriminatory preference: Example: A...
Read more >
12 CFR Appendix B to Part 701 - Chartering and Field of ...
NCUA has not set a minimum field of membership size for chartering a federal credit union. Consequently, groups of any size may apply...
Read more >
Abstract Types - GraphQL Nexus
This guide covers the topic of GraphQL Union types and Interface types. You will learn ... The spec states that this discriminant property...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found