I have the following code:
const a = {
type: "a",
childrenA: [ 1, 2, 3 ]
} as const;
const b = {
type: "b",
childrenB: [ 4, 5, 6 ]
} as const;
const HIERARCHY = {
a: "childrenA",
b: "childrenB",
} as const;
const getChildren = <T extends typeof a | typeof b>(obj: T) => {
const ret = obj[HIERARCHY[obj.type]];
return ret;
};
const AOrB = Math.random() > 0.5 ? a : b;
getChildren(AOrB);
I have multiple objects of different types that all use a different key to store a common array, in this case a
stores an array of numbers behind childrenA
and b
behind childrenB
. I am trying to write a function getChildren()
that can retrieve the common array from my objects. To know under which key the array is stored, there is the HIERARCHY
object which maps the type of object to the relevant key.
The code for the is relatively simple, yet I am not sure how to type this in typescript strict mode.
I believe I understand the problem though.
obj.type
is of type"a" | "b"
HIERARCHY[obj.type]
is of type"childrenA" | "childrenB"
- accessing both
a["childrenA"]
andb["childrenB"]
works, but I am not correctly telling ts that I will always access the correct property.
CodePudding user response:
As you noted, the problem is that the compiler only sees that the types of HIERARCHY[obj.type]
and obj
are both union types, but it doesn't have a way to represent the fact that they are correlated with each other. It only knows that you want to index into an object of a type like {childrenA: number[]} | {childrenB: number[]}
with an index of a type like "childrenA" | "childrenB"
. In general it's not valid to do this (e.g., obj[HIERARCHY[Math.random()<0.5 ? "a" : "b"]]
). We know that if obj
has a "childrenA"
property, then HIERARCHY[obj.type]
will be "childrenA"
, but the compiler isn't tracking the identity of the expressions, just their types.
This is a general issue I've been calling "correlated union types", and is the subject of microsoft/TypeScript#30581. For a long time the only advice I had to give people was to use a type assertion like
const getChildrenAssert = <T extends typeof a | typeof b>(
obj: T): readonly number[] => (
obj as unknown as Record<"childrenA" | "childrenB", readonly number[]>
)[HIERARCHY[obj.type]];
But microsoft/47109 suggested using a distributive object type, which is what you get when you make a mapped type over a set of keys, and then immediately index into it with that set of keys. That is, a type of the form {[P in K]: F<P>}[K]}
, where K
is some keylike type (or union of such types), and F<P>
is a type function that operates on keylike types. Such a form ends up distributing F<P>
over the union in K
. That is, if K
is K1 | K2 | K3
, then the distributive object type evaluates to F<K1> | F<K2> | F<K3>
.
For your case, it could look like this:
type Mapper = typeof HIERARCHY;
type Obj<K extends keyof Mapper> = { [P in K]:
{ type: P } & Record<Mapper[P], readonly number[]>
}[K]
If we plug "a"
into Obj
, we get the type of your a
:
type A = Obj<"a">;
/* type A = {
type: "a";
} & Record<"childrenA", readonly number[]> */
and the same with "b"
:
type B = Obj<"b">;
/* type B = {
type: "b";
} & Record<"childrenB", readonly number[]> */
And if we plug in the union "a" | "b"
, we get the union typeof a | typeof b
:
type AOrB = Obj<"a" | "b">
/* type AOrB = ({
type: "a";
} & Record<"childrenA", readonly number[]>) | ({
type: "b";
} & Record<"childrenB", readonly number[]>) */
And now we make getChildren()
generic in that key type:
const getChildren = <K extends keyof Mapper>(
obj: Obj<K>): readonly number[] => obj[HIERARCHY[obj.type]]; // okay
And there's no error at all. The compiler sees obj.type
as being of type K
, and that generic type stays generic all throughout HIERARCHY[obj.type]
and obj[HIERARCHY[obj.type]]
. The compiler sees the resulting output as assignable to readonly number[]
, and everything works as desired.