-2

playground link

type Props = (
  | {
    endpoint: 'constant-a' | 'constant-b';
    property: object
  }
  | {
    endpoint?: 'constant-c';
    property?: object
  }
) & ({
  isMulti: true
  property2: object
} | {
  isMulti?: false
  property2?: object
});


function breaks(props: Props) {
    return null;
}

const endpoint: 'constant-a' | 'constant-b' | 'constant-c' = '' as any;

// this breaks, but shouldn't. Since property and property2 are provided,
// any of the possible values of endpoint should be fine.
breaks({ endpoint, property: {}, property2: {} });

// this is fine.
breaks({ endpoint: 'constant-c' });

// this is _expected_ to break since property is missing.
breaks({ endpoint: 'constant-a' });

The message is:

Argument of type '{ endpoint: "constant-a" | "constant-b" | "constant-c"; }' is not assignable to parameter of type 'Props'.
  Type '{ endpoint: "constant-a" | "constant-b" | "constant-c"; }' is not assignable to type '{ endpoint?: "constant-c" | undefined; } & { isMulti?: false | undefined; }'.
    Type '{ endpoint: "constant-a" | "constant-b" | "constant-c"; }' is not assignable to type '{ endpoint?: "constant-c" | undefined; }'.
      Types of property 'endpoint' are incompatible.
        Type '"constant-a" | "constant-b" | "constant-c"' is not assignable to type '"constant-c" | undefined'.
          Type '"constant-a"' is not assignable to type '"constant-c"'.(2345)
(property) endpoint: "constant-a" | "constant-b" | "constant-c"

How can we support the behavior of the second and third calls to breaks() (where the second should succeed and the third should fail), while also supporting the overlap in the first call to breaks() (which should not fail).

4
  • Yeah in reality I also always pass that property at this call site (in other calls, endpoint is hardcoded and the extra properties then might not be passed when they are optional). So either side of the union would still have worked, even though only one side is optional. I omitted all the other properties in the question in the interest of simplifying the problem. Commented Oct 17 at 18:36
  • Indiscriminate loses the constraints I want. > So maybe just remove references to property: object in the question? I was trying to avoid answers like Indiscriminate where someone says "well just combine them and remove the union". I need the union to enforce other properties. > it seems like you're trying to make something work that really shouldn't work. I don't follow that part. Do you mean there is no fully abstract solution? The answer I shared works for the case I needed, at least. If unions have overlap, the goal is to allow the overlap. If they don't, then it won't work. Commented Oct 17 at 19:01
  • 1
    So now I'd say you're running afoul of ms/TS#58432 and you might want to refactor your union with more overlap. I think the version shown in this playground link is equivalent to yours, but works the way you expect. Does that fully address the question? if so I'll write an answer or find a duplicate; if not, what am I missing? Commented Oct 18 at 21:49
  • Yes that design limitation definitely sounds like the issue and that code in the playground link does seem to do the needful for this case. It still requires that one side is fully stricter than the other: e.g. if each element of the union required a different property, it wouldn't be sufficient. But that does seem to be the best we have. Commented Oct 19 at 22:09

1 Answer 1

1

TypeScript cannot, in general, relate source objects with union-typed properties to arbitrary target unions of objects with non-union properties. For example, every value of type {x: A | B, y: C | D, z: E | F} should be assignable to the union {x: A, y: C, z: E} | {x: B, y: C, z: E} | {x: A, y: D, z: E} | {x: B, y: D, z: E} | {x: A, y: C, z: F} | {x: B, y: C, z: F} | {x: A, y: D, z: F} | {x: B, y: D, z: F}, but it's a lot of work for the type checker to verify that. If I made a mistake on one of those union members, or left one out, then it wouldn't be true anymore.

TypeScript 3.5 introduced some support for this type of checking, as implemented in microsoft/TypeScript#30779, but it's fragile and limited in scope.

It looks like you've hit one such limitation, as described in microsoft/TypeScript#58432, where the discriminant property of a discriminated union is optional.


According to a comment from @RyanCavanaugh, "there's almost always a better way to write the target type". So let's see if we can do that with your example.

Your original type is

type Props =
  (
    { endpoint: 'constant-a' | 'constant-b'; property: object } |
    { endpoint?: 'constant-c'; property?: object }
  ) & (
    { isMulti: true; property2: object } |
    { isMulti?: false; property2?: object }
  );

which is equivalent to a union of four members (distributing the intersection over the union). And you're assigning a value of type {endpoint: 'constant-a' | 'constant-b' | 'constant-c'; property: object; property2: object} to it, which is assignable to none of the four union members of Props. Since we expect to be able to make such an assignment, we should try to see if we can take one of those union members and make it accept the assignment without also accepting unwanted assignments.

I'd suggest something like

type Props =
  (
    { endpoint: 'constant-a' | 'constant-b' | 'constant-c'; property: object } |
    { endpoint?: 'constant-c'; property?: object }
  ) & (
    { isMulti: boolean; property2: object } |
    { isMulti?: false; property2?: object }
  );

where I've widened some of union members to overlap other ones. I don't think this allows anything new: adding 'constant-c' as a possible endpoint to 'constant-a' | 'constant-b' only accepts things already accepted by the existing endpoint?: 'constant-c' member; and adding false as a possible isMulti to true only accepts things already accepted by the existing isMulti?: false member.

Now it works as you intend:

const endpoint: 'constant-a' | 'constant-b' | 'constant-c' = '' as any;
breaks({ endpoint, property: {}, property2: {} }); // okay
breaks({ endpoint: 'constant-c' }); // okay
breaks({ endpoint: 'constant-a' }); // error

So that answers the question as asked.


There might be some more general situation where you can't just rewrite the union without breaking a constraint, but if you plan to make a single assignment of a single source object with independent union-typed properties, then you can always find a way to make it work.

At the very least you should be able to add a union member corresponding to your assignment. That is, in general: if you have a value of type X and you're sure it should be assignable to type Y = A | B | C | D, but TypeScript doesn't think so, then you should be able to write type Y = A | B | C | D | X without changing things. After all, X is already supposed to be assignable to Y, so by adding X you're just being redundant, not permissive.

The only way it wouldn't work is if you either did conditional assignments, or a single assignment of dependent/correlated union-typed properties, where TypeScript can't keep track of the precise type of your object. (Like, if you wrote const v = Math.random() < 0.5 ? "a" : 1; const obj = {x:v, y:v}, then TypeScript would see this as {x: string | number; y: string | number} and not {x: string, y: string} | {x: number, y: number}.) But that doesn't seem to be what you're doing in this question, so it's probably out of scope here.

Playground link to code

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.