Reputation: 6544
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?
Upvotes: 5
Views: 3140
Reputation: 373
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;
}
Upvotes: -1
Reputation: 10217
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:
Key
, which exist in some part of the union,Part
of the union,Part[Key]
, if it exists, or undefined
otherwise,Key
to the union of these types for all Part
s.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:
T
(here we're using union distribution),K
in it, we return the T[K]
,undefined
,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');
Upvotes: 6
Reputation: 20230
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)
Upvotes: 5