Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
nebkat opened this issue Dec 13, 2022 · 8 comments · Fixed by #51884
Closed
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript

Comments

@nebkat
Copy link
Contributor

nebkat commented Dec 13, 2022

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" }.

@nebkat
Copy link
Contributor Author

nebkat commented Dec 13, 2022

In addition to my example use-case, there is a further debatable use-case:

interface Common {
    type: "A" | "B" | "C" | "D" | [...many];
}
interface Afoo {
    type: "A";
    foo?: number;
}
interface Abar {
    type: "A";
    bar?: number;
}

type CommonWithOptionals = Common | (Common & Afoo) | (Common & Abar);

Because the discriminator should technically resolve to (Common & Afoo) | (Common & Abar), EPC should accept { type: "A", foo: 1, bar: 1 }. This opens up the possibility to seamlessly "extend" the available properties in the union for a particular discriminator without having to re-define all of its constituents.

Strictly speaking, it should allow { a } or { b } but not both, since Common & Afoo & Abar is not explicitly listed. The question then becomes should this behavior be explicitly documented, or would it be counterintuitive considering normal union behavior? Currently there is no error raised and I think many developers already rely on such "quasioverlapping unions" behavior.

In my application this would offer a very elegant solution as my discriminator values are complex union types themselves, so mixing unions and intersections becomes quite tricky.

nebkat added a commit to nebkat/TypeScript that referenced this issue Dec 13, 2022
nebkat added a commit to nebkat/TypeScript that referenced this issue Dec 13, 2022
@nebkat
Copy link
Contributor Author

nebkat commented Dec 13, 2022

Potential fix: nebkat@f93cc34

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.

@fatcerberus
Copy link

// This has no excess error because variant one and three are both applicable.

That this is explicitly tested for would seem to suggest the behavior is by design.

@MartinJohns
Copy link
Contributor

MartinJohns commented Dec 13, 2022

Can possibly considered a duplicate of #20863, because you effectively don't have a discriminated union.

@nebkat
Copy link
Contributor Author

nebkat commented Dec 13, 2022

That this is explicitly tested for would seem to suggest the behavior is by design.

That is the logic I am questioning in this issue I suppose.

It seems to me more like an unintentional byproduct of the restrictive nature of the discriminator EPC check. If that behavior was desired the union should have included an intersection type allowing all properties.

Alternatively the entire union should be marked ambiguous and there should be no EPC even on unambigious discriminators. We are allowing literals to be assigned to an ambiguous A | B that would not be assignable to A alone or B alone, yet we don't allow this with an unambiguous C.

Had me scratching my head for a while because in some cases the union acts as an intersection, in others not. The EPC strikes a good balance effectively treating unions constituents as exact types while allowing overlap where there is uncertainty, it is just falling slightly short in this case.

Can possibly considered a duplicate of #20863, because you effectively don't have a discriminated union.

The key difference and the reason I created a separate issue is that, if I have understood it correctly, it becomes prohibitively expensive to discriminate based on arbitrary combinations of properties, whereas in my examples there is always a single discriminator property.

The function to find the reduced discriminator type is already checking each union constituent for a match to the discriminator property, but it is discarding the matches and returning the entire union if there is more than one match.

My proposed change just returns a new subset union of the multiple matches, which could actually be more efficient, unless there is a significant overhead associated with creating the new subset union and calculating its properties.

@fatcerberus
Copy link

fatcerberus commented Dec 13, 2022

FWIW, per #20863 (comment):

Excess property checking of unions does not work because all it does is try to find a single member of the union to check for excess properties. … I chose this design because it was conservative enough to avoid bogus errors, if I remember correctly.

So this was definitely intentional at one point, but that comment was also made almost 5 years ago. So I’m as interested as you are to see what the maintainers will say about it—and your proposed change—now.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Experimentation Needed Someone needs to try this out to see what happens labels Dec 13, 2022
@RyanCavanaugh
Copy link
Member

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?

@pedro757
Copy link

I believe this could help to illustrate the problem

type Animal = {
  name: "cat" | "dog";
  pet: boolean;
  weight: number;
} | {
  name: "lion";
  pet: boolean;
}

// Cat is Never
type Cat = Extract<Animal, { name: "cat" }>

// Lion type is { name: "lion", pet: boolean }
type Lion = Extract<Animal, { name: "lion" }>

Typescript is not smart enough to get the type here.

TS Playground here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants