5

This is what I want to achieve:

interface Point2d {
  x: number;
  y: number;
}

interface Point3d {
  x: number;
  y: number;
  z: number;
}

type Point = Point2d | Point3d;

const p: Point = getPoint();
const z: number | undefined = p.z;

However, this is not possible: TypeScript will error on the last line that z is not defined on Point2d. Is there any way to make this work?

Ben Smith
  • 19,589
  • 6
  • 65
  • 93
Tiddo
  • 6,331
  • 6
  • 52
  • 85

3 Answers3

5

You could use a type guard to check for the presence of z e.g.

const p: Point = getPoint();
const z: number | undefined = ('z' in p) ? p.z : undefined; 

You can see an example of this here.

Or you could make this more generic with a function such as:

const get = (point: Point, prop: string): number => prop in point ? point[prop] : undefined

The in keyword is actually a javascript operator, but its oft used in scenarios like this in typescript (e.g. see a nice example here)

Ben Smith
  • 19,589
  • 6
  • 65
  • 93
  • Thanks for the answer, you're right. Unfortunately I realize that I oversimplified my example compared to the real-world problems. These type guards indeed work well for a localized case, but don't scale very well. E.g. consider having to do this for several properties in a row, it'll introduce a lot of boilerplate. Ideally we'd introduce something like `get(p, 'z')`, but I can't figure out how to do that. – Tiddo Apr 29 '19 at 14:04
  • I've added an example to my answer of a method like what you proposed above. – Ben Smith Apr 29 '19 at 14:13
  • Unfortunately, that function throws away any types and just returns `any`. Usually we'd type such functions with `prop: keyof Point`, but `keyof Point` only accepts properties that are in all members of the union, i.e. `'x' | 'y'` but not `'z'` – Tiddo Apr 29 '19 at 14:28
  • Yes, whilst this arrow function is not truly generic for any type of input, you could constrain the result to type number instead of any. TBH I'll be interested to see if there is a truly generic way of achieving what you want! – Ben Smith Apr 29 '19 at 14:41
  • @Ben Check my answer below - perhaps that's what you're about? – Cerberus Apr 30 '19 at 13:08
  • @Cerberus Very nice answer you gave above. I needed a quiet five minutes to work thorough that! – Ben Smith May 01 '19 at 15:59
5

This problem (and especially the discussion) caught my attention, and that's what I've managed to do.

First of all, we can try and use the UnionToIntersection type, to get the type including all properties from all elements of union. The first attempt was like this:

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type Intersect = UnionToIntersection<Point>;
type Keys = keyof Intersect;
function check<K extends Keys>(p: Point, key: K): p is Point & { [key in K]: Point[key] }  { 
    return key in p;
 };
function get<K extends Keys>(p: Point, key: K) {
    return check(p, key) ? p[key] : undefined;
};

The idea is as following: if the property is there - we'll restrict the type, requiring that it is really here, in addition to what we knew before; and then just return this type. This code, however, fails to compile: Type 'key' cannot be used to index type 'Point'. That is somewhat expectable, since we have no guarantee that the indexing type is correct for any arbitrary Point (after all, that is the problem we're trying to solve from the start).

The fix is rather simple:

function check<K extends Keys>(p: Point, key: K): p is Point & { [key in K]: Intersect[key] } { 
    return key in p;
 };
function get<K extends Keys>(p: Point, key: K) {
    return check(p, key) ? p[key] : undefined;
};
const z = get(p, 'z');
if (z) {
    console.log(z.toFixed()); // this typechecks - 'z' is of type 'number'
}

However, this is not the end. Imagine that for some reason we need to store Point3d x coordinate as string, not number. The code above will fail in this case (I'll paste the whole block here, so we won't mix it with the code in question):

interface Point2d {
    x: number;
    y: number;
}

interface Point3d {
    x: string;
    y: number;
    z: number;
}

type Point = Point2d | Point3d;

function getPoint(): Point { throw ("unimplemented"); };
const p: Point = getPoint();

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type Intersect = UnionToIntersection<Point>;
type Keys = keyof Intersect;

function check<K extends Keys>(p: Point, key: K): p is Point & { [key in K]: Intersect[key] } { 
    return key in p;
 };
function get<K extends Keys>(p: Point, key: K) {
    return check(p, key) ? p[key] : undefined;
};

const x = get(p, 'x');
if (x && typeof x === 'number') {
    console.log(x.toFixed()); // error: property 'toFixed' does not exist on type 'never'
}

The problem is that in the intersection the x property must be a number and a string at the same time, and this is impossible, so it is inferred to never.

It seems that we must think of some other way. Let's try to describe what we want:

  • for every Key, which exist in some part of the union,
  • and for every Part of the union,
  • we want to have the type of Part[Key], if it exists, or undefined otherwise,
  • and we want to map Key to the union of these types for all Parts.

Well, let's start by literally translating this into type system:

type ValueOfUnion<T, K> = T extends any ? K extends keyof T ? T[K] : undefined : never;

This is pretty straightforward:

  • for any type in the union T (here we're using union distribution),
  • if there is a key K in it, we return the T[K],
  • else we return undefined,
  • and the lase clause is just a syntactic part, which never holds (since all types extend any).

Now, let's continue by creating a mapping:

type UnionMapping<T> = {
    [K in keyof UnionToIntersection<T>]: ValueOfUnion<T, K>;
}
type MappedPoint = UnionMapping<Point>;

Again, this is clear: for any key K existing in the intersection (we already know how to create it), we grab the corresponding values.

And, finally, the getter, which becomes ridiculously simple:

function get<K extends keyof MappedPoint>(p: Point, key: K) { 
    return (p as MappedPoint)[key]
};

The type assertion here is correct, since every concrete Point is a MappedPoint. Note that we can't just require that get function recieves a MappedPoint, since TypeScript will be angry:

Argument of type 'Point' is not assignable to parameter of type 'UnionMapping<Point>'.
  Property 'z' is missing in type 'Point2d' but required in type 'UnionMapping<Point>'.

The problem is that mapping loses the optionality introduced by the union (replacing it by the possible undefined output).

Short testing shows that this indeed works:

const x = get(p, 'x'); // x is number | string - note that we got rid of unnecessary "undefined"
if (typeof x === 'number') {
    console.log(x.toFixed());
} else {
    console.log(x.toLowerCase());
}

const z = get(p, 'z'); // z is number | undefined, since it doesn't exist on some part of union
if (z) {
    console.log('Point 3d with z = ' + z.toFixed())
} else {
    console.log('Point2d')
}

Hope that helps!


Update: comment by the topic starter suggested the following construction for further ergonomics:

type UnionMapping<T> = {
    [K in keyof UnionToIntersection<T> | keyof T]: ValueOfUnion<T, K>;
}
type UnionMerge<T> = Pick<UnionMapping<T>, keyof T> & Partial<UnionMapping<T>>;

function get<K extends keyof UnionMerge<Point>>(p: UnionMerge<Point>, key: K) { 
    return p[key];
};

The idea is to get rid of unnecessary type assertions by making the properties really optional (and not just allow passing undefined).

Furthermore, using the idea of currying, we can get this construction generalized for many union types at once. It's hard to get working in current formulation, since we can't pass one type explicitly and let TypeScript infer the other one, and this function declaration infers type T incorrectly:

function get<T, K extends keyof UnionMerge<Point>>(p: UnionMerge<T>, key: K) { 
    return p[key];
};
get(p, 'z'); // error, since T is inferred as {x: {}, y: {}}

But, by splitting the function into two parts, we can supply the union type explicitly:

function getter<T>(p: UnionMerge<T>) {
    return <K extends keyof UnionMerge<T>>(key: K) => p[key];
}

const pointGetter = getter<Point>(p);

The same tests as above are now passed:

const x = pointGetter('x');
if (typeof x === 'number') {
    console.log(x.toFixed());
} else {
    console.log(x.toLowerCase());
}

const z = pointGetter('z');
if (z) {
    console.log('Point 3d with z = ' + z.toFixed())
} else {
    console.log('Point2d')
}

And this can be used without explicit intermediate object (although it looks a bit unusual):

const x = getter<Point>(p)('x');
Cerberus
  • 8,879
  • 1
  • 25
  • 40
  • 1
    You knocked it out of the park, amazing! Based on your answer, I managed to get rid of the type assertion as well: `keyof Point` gives the keys that are shared, i.e. `'x' | 'y'`. We can use this to construct a new type where `z` is truly optional: `type P = Pick & Partial`. To make it generic: `type UnionMerge = Pick, keyof T> & Partial>`. This requires a slight change to `UnionMapping`: `[K in keyof UnionToIntersection | keyof T]: ...` (1/2) – Tiddo May 01 '19 at 10:14
  • This last change is needed because TS doesn't understand that `keyof T` is a subtype of `UnionToIntersection`, though it's trivial for us to see. Now, any `Point` is a subtype of `UnionMerge`, and thus we don't need any casts anymore. (2/2) – Tiddo May 01 '19 at 10:17
  • @Cerberus it might be worth updating you answer with Tiddo's suggested changes in the comments above. I can see this answer being very useful to others with this problem! – Ben Smith May 01 '19 at 16:09
  • 1
    Done! And created one more variant of further generalization. – Cerberus May 01 '19 at 16:58
  • Keep in mind that `keyof UnionToIntersection` can be simplified with `KeysOfUnion` by using `type KeysOfUnion = T extends any ? keyof T: never;`, making `UnionToIntersection` definition unnecessary. – Tomás Fox Nov 27 '20 at 19:55
  • One thing to note here is that if we do `let p: MappedPoint; if (p.z) {p.x}` we have `p.x` as `number | string`, while it should be `string`. I'm still looking for some way to do this. I could find a way to do it with a finite number of types, but can't find a way to do it with any number of types in the union. – Tomás Fox Nov 27 '20 at 19:56
  • This answer is too complicated. There are much simpler solutions. See other answers. – Derek718 Dec 02 '20 at 16:32
-1

I recommend using Discriminated Unions. This is simpler and provides stronger typing than the other solutions I've seen.

interface Point2d {
  d: 2;
  x: number;
  y: number;
}

interface Point3d {
  d: 3;
  x: number;
  y: number;
  z: number;
}

type Point = Point2d | Point3d;

const p: Point = getPoint();
let z: number | undefined = undefined;
if (p.d === 3) {
  z = p.z;
}

If you don't want to add the d property, you can simply check if z exists using the in operator. See this documentation for details. This solution isn't as robust as using discriminated unions, especially for more complex problems, but it gets the job done for simple problems like this one.

interface Point2d {
 x: number;
 y: number;
}

interface Point3d {
 x: number;
 y: number;
 z: number;
}

type Point = Point2d | Point3d;

const p: Point = getPoint();
let z: number | undefined = undefined;
if ("z" in p) {
 z = p.z;
}
Derek718
  • 273
  • 1
  • 7
  • 1
    Hey, thanks for your reply. This is very similar to Ben's solution, and unfortunately has the same issues. See also the discussion there. – Tiddo Dec 02 '20 at 18:19
  • @Tiddo Yes, I see what you said about your question being much simpler than the real problem. However, I think the discriminated unions solution would work well even for a very complex problem (but of course it is hard to tell for sure without seeing the specific complex problem). Discriminated unions scale very well and are used in very complex systems. Why wouldn't it work in your case? – Derek718 Dec 02 '20 at 18:30