镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

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

Closed

Description

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

Activity

  1. nebkat commented on Dec 13, 2022

    @nebkat
    ContributorAuthor

    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.

  2. added 2 commits that reference this issue on Dec 13, 2022
    8fbca56
    f93cc34
  3. nebkat commented on Dec 13, 2022

    @nebkat
    ContributorAuthor

    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.

  4. fatcerberus commented on Dec 13, 2022

    @fatcerberus

    // 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.

  5. MartinJohns commented on Dec 13, 2022

    @MartinJohns
    Contributor

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

  6. nebkat commented on Dec 13, 2022

    @nebkat
    ContributorAuthor

    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.

  7. fatcerberus commented on Dec 13, 2022

    @fatcerberus

    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.

  8. RyanCavanaugh commented on Dec 13, 2022

    @RyanCavanaugh
    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?

  9. added 2 commits that reference this issue on Dec 14, 2022
    5f184e9
    ca945a2
  10. pedro757 commented on Feb 11, 2023

    @pedro757

    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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Experimentation NeededSomeone needs to try this out to see what happensSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions