TypeScript: Get deeply nested property value using array
up vote
6
down vote
favorite
I'd like to declare a function that can take an object plus an array of nested property keys and derive the type of the nested value as the return type of the function.
e.g.
const value = byPath({ state: State, path: ['one', 'two', 'three'] });
// return type == State['one']['two']['three']
const value2 = byPath({ state: State, path: ['one', 'two'] });
// return type == State['one']['two']
The best I've been able to put together is the following, but it is more verbose than I'd like it to be, and I have to add a function overload for every level of nesting.
export function byPath<
K1 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: R},
path: [K1]
}): R;
export function byPath<
K1 extends string,
K2 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: {[P2 in K2]?: R}},
path: [K1, K2]
}): R;
export function byPath<
K1 extends string,
K2 extends string,
K3 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: {[P2 in K2]?: {[P3 in K3]?: R}}},
path: [K1, K2, K3]
}): R;
export function byPath<R>({ state, path }: { state: State, path: string }): R | undefined {
// do the actual nested property retrieval
}
Is there a simpler / better way to do this?
typescript
add a comment |
up vote
6
down vote
favorite
I'd like to declare a function that can take an object plus an array of nested property keys and derive the type of the nested value as the return type of the function.
e.g.
const value = byPath({ state: State, path: ['one', 'two', 'three'] });
// return type == State['one']['two']['three']
const value2 = byPath({ state: State, path: ['one', 'two'] });
// return type == State['one']['two']
The best I've been able to put together is the following, but it is more verbose than I'd like it to be, and I have to add a function overload for every level of nesting.
export function byPath<
K1 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: R},
path: [K1]
}): R;
export function byPath<
K1 extends string,
K2 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: {[P2 in K2]?: R}},
path: [K1, K2]
}): R;
export function byPath<
K1 extends string,
K2 extends string,
K3 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: {[P2 in K2]?: {[P3 in K3]?: R}}},
path: [K1, K2, K3]
}): R;
export function byPath<R>({ state, path }: { state: State, path: string }): R | undefined {
// do the actual nested property retrieval
}
Is there a simpler / better way to do this?
typescript
I think this is an awesome question. Maybe it is possible to recursively traverse the path array during compile time by using spread types (path: [KH, ...KT]
), but unfortunately this is not supported in TS 3.1. Nevertheless, I tried to come up with an untested POC; Maybe it will work if TS supports rest types in tuples.
– Johannes Reuter
Nov 14 at 8:35
add a comment |
up vote
6
down vote
favorite
up vote
6
down vote
favorite
I'd like to declare a function that can take an object plus an array of nested property keys and derive the type of the nested value as the return type of the function.
e.g.
const value = byPath({ state: State, path: ['one', 'two', 'three'] });
// return type == State['one']['two']['three']
const value2 = byPath({ state: State, path: ['one', 'two'] });
// return type == State['one']['two']
The best I've been able to put together is the following, but it is more verbose than I'd like it to be, and I have to add a function overload for every level of nesting.
export function byPath<
K1 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: R},
path: [K1]
}): R;
export function byPath<
K1 extends string,
K2 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: {[P2 in K2]?: R}},
path: [K1, K2]
}): R;
export function byPath<
K1 extends string,
K2 extends string,
K3 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: {[P2 in K2]?: {[P3 in K3]?: R}}},
path: [K1, K2, K3]
}): R;
export function byPath<R>({ state, path }: { state: State, path: string }): R | undefined {
// do the actual nested property retrieval
}
Is there a simpler / better way to do this?
typescript
I'd like to declare a function that can take an object plus an array of nested property keys and derive the type of the nested value as the return type of the function.
e.g.
const value = byPath({ state: State, path: ['one', 'two', 'three'] });
// return type == State['one']['two']['three']
const value2 = byPath({ state: State, path: ['one', 'two'] });
// return type == State['one']['two']
The best I've been able to put together is the following, but it is more verbose than I'd like it to be, and I have to add a function overload for every level of nesting.
export function byPath<
K1 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: R},
path: [K1]
}): R;
export function byPath<
K1 extends string,
K2 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: {[P2 in K2]?: R}},
path: [K1, K2]
}): R;
export function byPath<
K1 extends string,
K2 extends string,
K3 extends string,
R
>({ state, path }: {
state: {[P1 in K1]?: {[P2 in K2]?: {[P3 in K3]?: R}}},
path: [K1, K2, K3]
}): R;
export function byPath<R>({ state, path }: { state: State, path: string }): R | undefined {
// do the actual nested property retrieval
}
Is there a simpler / better way to do this?
typescript
typescript
asked Nov 14 at 4:28
bingles
5,29444855
5,29444855
I think this is an awesome question. Maybe it is possible to recursively traverse the path array during compile time by using spread types (path: [KH, ...KT]
), but unfortunately this is not supported in TS 3.1. Nevertheless, I tried to come up with an untested POC; Maybe it will work if TS supports rest types in tuples.
– Johannes Reuter
Nov 14 at 8:35
add a comment |
I think this is an awesome question. Maybe it is possible to recursively traverse the path array during compile time by using spread types (path: [KH, ...KT]
), but unfortunately this is not supported in TS 3.1. Nevertheless, I tried to come up with an untested POC; Maybe it will work if TS supports rest types in tuples.
– Johannes Reuter
Nov 14 at 8:35
I think this is an awesome question. Maybe it is possible to recursively traverse the path array during compile time by using spread types (
path: [KH, ...KT]
), but unfortunately this is not supported in TS 3.1. Nevertheless, I tried to come up with an untested POC; Maybe it will work if TS supports rest types in tuples.– Johannes Reuter
Nov 14 at 8:35
I think this is an awesome question. Maybe it is possible to recursively traverse the path array during compile time by using spread types (
path: [KH, ...KT]
), but unfortunately this is not supported in TS 3.1. Nevertheless, I tried to come up with an untested POC; Maybe it will work if TS supports rest types in tuples.– Johannes Reuter
Nov 14 at 8:35
add a comment |
1 Answer
1
active
oldest
votes
up vote
3
down vote
accepted
Unfortunately, TypeScript doesn't currently allow arbitrary recursive type functions, which is what you want to iterate through a list of keys, drill down into an object type, and come out with the type of the nested property corresponding to the list of keys. You can do pieces of it, but it's a mess.
So you're going to have to pick some maximum level of nesting and write for that. Here's a possible type signature for your function which doesn't use overloads:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T;
declare function byPath<T0,
K1 extends keyof T0 | undefined, T1 extends IfKey<T0, K1>,
K2 extends keyof T1 | undefined, T2 extends IfKey<T1, K2>,
K3 extends keyof T2 | undefined, T3 extends IfKey<T2, K3>,
K4 extends keyof T3 | undefined, T4 extends IfKey<T3, K4>,
K5 extends keyof T4 | undefined, T5 extends IfKey<T4, K5>,
K6 extends keyof T5 | undefined, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
Note that you can easily extend that to more than six layers of nesting if you need to.
The way it works: there are two kinds of type parameters... key types (named K1
, K2
, etc), and object types (named T0
, T1
, etc). The state
property is of type T0
, and the path is a tuple with optional elements of the key types. Each key type is either a key of the previous object type, or it's undefined
. If the key is undefined, then the next object type is the same as the current object type; otherwise it's the type of the relevant property. So as soon as key types become and stay undefined
, the object types become and stay the last relevant property type... and the last object type (T6
above) is the return type of the function.
Let's do an example: if T0
is {a: {b: string}, c: {d: string}}
, then K1
must be one of 'a'
, 'd'
, or undefined
. Let's say that K1
is 'a'
. Then T1
is {b: string}
. Now K2
must be 'b'
or undefined
. Let's say that K2
is 'b'
. Then T2
is string
. Now K3
must be in keyof string
or undefined
. (So K3
could be "charAt"
, or any of the string
methods and properties). Let's say that K3
is undefined
. Then T3
is string
(since it is the same as T2
). And if all the rest of K4
, K5
, and K6
are undefined
, then T4
, T5
, and T6
are just string
. And the function returns T6
.
So if you do this call:
const ret = byPath({state: {a: {b: "hey"}, c: {d: "you"} }, path: ['a', 'b'] });
Then T0
will be inferred as {a: {b: string}, c: {d: string}
, K1
will be 'a'
, K2
will be 'b'
, and K3
through K6
will all be undefined
. Which is the example above, so T6
will be string
. And thus ret
will of type string
.
The above function signature should also yell at you if you enter a bad key:
const whoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['a', 'B'] });
// error! type "B" is not assignable to "b" | undefined: ----------------------> ~~~
That error makes sense, since B
is not valid. The following also yells at you:
const alsoWhoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['A', 'b'] });
// error! type "A" is not assignable to "a" | "c" | undefined: ---------------> ~~~
// also error! Type "b" is not assignable to "a" | "c" | undefined ?! -------------> ~~~
The first error is exactly what you'd expect; the second is a little weird, since "b"
is fine. But the compiler now has no idea what to expect for keyof T['A']
, so it is acting as if K1
were undefined
. If you fix the first error, the second will go away. There might be ways to alter the byPath()
signature to avoid this, but it seems minor to me.
Anyway, hope that helps you or gives you some ideas. Good luck!
EDIT: in case you care about that erroneous second error message you could use the slightly more complex:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T
type NextKey<T, K = keyof any> = [K] extends [undefined] ? undefined :
[keyof T | undefined] extends [K] ? keyof any : (keyof T | undefined)
declare function byPath<T0,
K1 extends NextKey<T0>, T1 extends IfKey<T0, K1>,
K2 extends NextKey<T1, K1>, T2 extends IfKey<T1, K2>,
K3 extends NextKey<T2, K2>, T3 extends IfKey<T2, K3>,
K4 extends NextKey<T3, K3>, T4 extends IfKey<T3, K4>,
K5 extends NextKey<T4, K4>, T5 extends IfKey<T4, K5>,
K6 extends NextKey<T5, K5>, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
which is pretty much the same except for when things go wrong with keys not matching what they're supposed to match.
Seems to not work on Partial<Record<T>> types. Any thoughts as to why?
– bingles
Nov 15 at 21:11
1
I ended up solving with type IfKey<T, K> = [K] extends [keyof T] ? Exclude<Required<T[K]>, undefined> : T; and then return type of T6 extends Required<infer U> ? U : T6
– bingles
Nov 15 at 22:04
add a comment |
1 Answer
1
active
oldest
votes
1 Answer
1
active
oldest
votes
active
oldest
votes
active
oldest
votes
up vote
3
down vote
accepted
Unfortunately, TypeScript doesn't currently allow arbitrary recursive type functions, which is what you want to iterate through a list of keys, drill down into an object type, and come out with the type of the nested property corresponding to the list of keys. You can do pieces of it, but it's a mess.
So you're going to have to pick some maximum level of nesting and write for that. Here's a possible type signature for your function which doesn't use overloads:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T;
declare function byPath<T0,
K1 extends keyof T0 | undefined, T1 extends IfKey<T0, K1>,
K2 extends keyof T1 | undefined, T2 extends IfKey<T1, K2>,
K3 extends keyof T2 | undefined, T3 extends IfKey<T2, K3>,
K4 extends keyof T3 | undefined, T4 extends IfKey<T3, K4>,
K5 extends keyof T4 | undefined, T5 extends IfKey<T4, K5>,
K6 extends keyof T5 | undefined, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
Note that you can easily extend that to more than six layers of nesting if you need to.
The way it works: there are two kinds of type parameters... key types (named K1
, K2
, etc), and object types (named T0
, T1
, etc). The state
property is of type T0
, and the path is a tuple with optional elements of the key types. Each key type is either a key of the previous object type, or it's undefined
. If the key is undefined, then the next object type is the same as the current object type; otherwise it's the type of the relevant property. So as soon as key types become and stay undefined
, the object types become and stay the last relevant property type... and the last object type (T6
above) is the return type of the function.
Let's do an example: if T0
is {a: {b: string}, c: {d: string}}
, then K1
must be one of 'a'
, 'd'
, or undefined
. Let's say that K1
is 'a'
. Then T1
is {b: string}
. Now K2
must be 'b'
or undefined
. Let's say that K2
is 'b'
. Then T2
is string
. Now K3
must be in keyof string
or undefined
. (So K3
could be "charAt"
, or any of the string
methods and properties). Let's say that K3
is undefined
. Then T3
is string
(since it is the same as T2
). And if all the rest of K4
, K5
, and K6
are undefined
, then T4
, T5
, and T6
are just string
. And the function returns T6
.
So if you do this call:
const ret = byPath({state: {a: {b: "hey"}, c: {d: "you"} }, path: ['a', 'b'] });
Then T0
will be inferred as {a: {b: string}, c: {d: string}
, K1
will be 'a'
, K2
will be 'b'
, and K3
through K6
will all be undefined
. Which is the example above, so T6
will be string
. And thus ret
will of type string
.
The above function signature should also yell at you if you enter a bad key:
const whoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['a', 'B'] });
// error! type "B" is not assignable to "b" | undefined: ----------------------> ~~~
That error makes sense, since B
is not valid. The following also yells at you:
const alsoWhoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['A', 'b'] });
// error! type "A" is not assignable to "a" | "c" | undefined: ---------------> ~~~
// also error! Type "b" is not assignable to "a" | "c" | undefined ?! -------------> ~~~
The first error is exactly what you'd expect; the second is a little weird, since "b"
is fine. But the compiler now has no idea what to expect for keyof T['A']
, so it is acting as if K1
were undefined
. If you fix the first error, the second will go away. There might be ways to alter the byPath()
signature to avoid this, but it seems minor to me.
Anyway, hope that helps you or gives you some ideas. Good luck!
EDIT: in case you care about that erroneous second error message you could use the slightly more complex:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T
type NextKey<T, K = keyof any> = [K] extends [undefined] ? undefined :
[keyof T | undefined] extends [K] ? keyof any : (keyof T | undefined)
declare function byPath<T0,
K1 extends NextKey<T0>, T1 extends IfKey<T0, K1>,
K2 extends NextKey<T1, K1>, T2 extends IfKey<T1, K2>,
K3 extends NextKey<T2, K2>, T3 extends IfKey<T2, K3>,
K4 extends NextKey<T3, K3>, T4 extends IfKey<T3, K4>,
K5 extends NextKey<T4, K4>, T5 extends IfKey<T4, K5>,
K6 extends NextKey<T5, K5>, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
which is pretty much the same except for when things go wrong with keys not matching what they're supposed to match.
Seems to not work on Partial<Record<T>> types. Any thoughts as to why?
– bingles
Nov 15 at 21:11
1
I ended up solving with type IfKey<T, K> = [K] extends [keyof T] ? Exclude<Required<T[K]>, undefined> : T; and then return type of T6 extends Required<infer U> ? U : T6
– bingles
Nov 15 at 22:04
add a comment |
up vote
3
down vote
accepted
Unfortunately, TypeScript doesn't currently allow arbitrary recursive type functions, which is what you want to iterate through a list of keys, drill down into an object type, and come out with the type of the nested property corresponding to the list of keys. You can do pieces of it, but it's a mess.
So you're going to have to pick some maximum level of nesting and write for that. Here's a possible type signature for your function which doesn't use overloads:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T;
declare function byPath<T0,
K1 extends keyof T0 | undefined, T1 extends IfKey<T0, K1>,
K2 extends keyof T1 | undefined, T2 extends IfKey<T1, K2>,
K3 extends keyof T2 | undefined, T3 extends IfKey<T2, K3>,
K4 extends keyof T3 | undefined, T4 extends IfKey<T3, K4>,
K5 extends keyof T4 | undefined, T5 extends IfKey<T4, K5>,
K6 extends keyof T5 | undefined, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
Note that you can easily extend that to more than six layers of nesting if you need to.
The way it works: there are two kinds of type parameters... key types (named K1
, K2
, etc), and object types (named T0
, T1
, etc). The state
property is of type T0
, and the path is a tuple with optional elements of the key types. Each key type is either a key of the previous object type, or it's undefined
. If the key is undefined, then the next object type is the same as the current object type; otherwise it's the type of the relevant property. So as soon as key types become and stay undefined
, the object types become and stay the last relevant property type... and the last object type (T6
above) is the return type of the function.
Let's do an example: if T0
is {a: {b: string}, c: {d: string}}
, then K1
must be one of 'a'
, 'd'
, or undefined
. Let's say that K1
is 'a'
. Then T1
is {b: string}
. Now K2
must be 'b'
or undefined
. Let's say that K2
is 'b'
. Then T2
is string
. Now K3
must be in keyof string
or undefined
. (So K3
could be "charAt"
, or any of the string
methods and properties). Let's say that K3
is undefined
. Then T3
is string
(since it is the same as T2
). And if all the rest of K4
, K5
, and K6
are undefined
, then T4
, T5
, and T6
are just string
. And the function returns T6
.
So if you do this call:
const ret = byPath({state: {a: {b: "hey"}, c: {d: "you"} }, path: ['a', 'b'] });
Then T0
will be inferred as {a: {b: string}, c: {d: string}
, K1
will be 'a'
, K2
will be 'b'
, and K3
through K6
will all be undefined
. Which is the example above, so T6
will be string
. And thus ret
will of type string
.
The above function signature should also yell at you if you enter a bad key:
const whoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['a', 'B'] });
// error! type "B" is not assignable to "b" | undefined: ----------------------> ~~~
That error makes sense, since B
is not valid. The following also yells at you:
const alsoWhoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['A', 'b'] });
// error! type "A" is not assignable to "a" | "c" | undefined: ---------------> ~~~
// also error! Type "b" is not assignable to "a" | "c" | undefined ?! -------------> ~~~
The first error is exactly what you'd expect; the second is a little weird, since "b"
is fine. But the compiler now has no idea what to expect for keyof T['A']
, so it is acting as if K1
were undefined
. If you fix the first error, the second will go away. There might be ways to alter the byPath()
signature to avoid this, but it seems minor to me.
Anyway, hope that helps you or gives you some ideas. Good luck!
EDIT: in case you care about that erroneous second error message you could use the slightly more complex:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T
type NextKey<T, K = keyof any> = [K] extends [undefined] ? undefined :
[keyof T | undefined] extends [K] ? keyof any : (keyof T | undefined)
declare function byPath<T0,
K1 extends NextKey<T0>, T1 extends IfKey<T0, K1>,
K2 extends NextKey<T1, K1>, T2 extends IfKey<T1, K2>,
K3 extends NextKey<T2, K2>, T3 extends IfKey<T2, K3>,
K4 extends NextKey<T3, K3>, T4 extends IfKey<T3, K4>,
K5 extends NextKey<T4, K4>, T5 extends IfKey<T4, K5>,
K6 extends NextKey<T5, K5>, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
which is pretty much the same except for when things go wrong with keys not matching what they're supposed to match.
Seems to not work on Partial<Record<T>> types. Any thoughts as to why?
– bingles
Nov 15 at 21:11
1
I ended up solving with type IfKey<T, K> = [K] extends [keyof T] ? Exclude<Required<T[K]>, undefined> : T; and then return type of T6 extends Required<infer U> ? U : T6
– bingles
Nov 15 at 22:04
add a comment |
up vote
3
down vote
accepted
up vote
3
down vote
accepted
Unfortunately, TypeScript doesn't currently allow arbitrary recursive type functions, which is what you want to iterate through a list of keys, drill down into an object type, and come out with the type of the nested property corresponding to the list of keys. You can do pieces of it, but it's a mess.
So you're going to have to pick some maximum level of nesting and write for that. Here's a possible type signature for your function which doesn't use overloads:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T;
declare function byPath<T0,
K1 extends keyof T0 | undefined, T1 extends IfKey<T0, K1>,
K2 extends keyof T1 | undefined, T2 extends IfKey<T1, K2>,
K3 extends keyof T2 | undefined, T3 extends IfKey<T2, K3>,
K4 extends keyof T3 | undefined, T4 extends IfKey<T3, K4>,
K5 extends keyof T4 | undefined, T5 extends IfKey<T4, K5>,
K6 extends keyof T5 | undefined, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
Note that you can easily extend that to more than six layers of nesting if you need to.
The way it works: there are two kinds of type parameters... key types (named K1
, K2
, etc), and object types (named T0
, T1
, etc). The state
property is of type T0
, and the path is a tuple with optional elements of the key types. Each key type is either a key of the previous object type, or it's undefined
. If the key is undefined, then the next object type is the same as the current object type; otherwise it's the type of the relevant property. So as soon as key types become and stay undefined
, the object types become and stay the last relevant property type... and the last object type (T6
above) is the return type of the function.
Let's do an example: if T0
is {a: {b: string}, c: {d: string}}
, then K1
must be one of 'a'
, 'd'
, or undefined
. Let's say that K1
is 'a'
. Then T1
is {b: string}
. Now K2
must be 'b'
or undefined
. Let's say that K2
is 'b'
. Then T2
is string
. Now K3
must be in keyof string
or undefined
. (So K3
could be "charAt"
, or any of the string
methods and properties). Let's say that K3
is undefined
. Then T3
is string
(since it is the same as T2
). And if all the rest of K4
, K5
, and K6
are undefined
, then T4
, T5
, and T6
are just string
. And the function returns T6
.
So if you do this call:
const ret = byPath({state: {a: {b: "hey"}, c: {d: "you"} }, path: ['a', 'b'] });
Then T0
will be inferred as {a: {b: string}, c: {d: string}
, K1
will be 'a'
, K2
will be 'b'
, and K3
through K6
will all be undefined
. Which is the example above, so T6
will be string
. And thus ret
will of type string
.
The above function signature should also yell at you if you enter a bad key:
const whoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['a', 'B'] });
// error! type "B" is not assignable to "b" | undefined: ----------------------> ~~~
That error makes sense, since B
is not valid. The following also yells at you:
const alsoWhoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['A', 'b'] });
// error! type "A" is not assignable to "a" | "c" | undefined: ---------------> ~~~
// also error! Type "b" is not assignable to "a" | "c" | undefined ?! -------------> ~~~
The first error is exactly what you'd expect; the second is a little weird, since "b"
is fine. But the compiler now has no idea what to expect for keyof T['A']
, so it is acting as if K1
were undefined
. If you fix the first error, the second will go away. There might be ways to alter the byPath()
signature to avoid this, but it seems minor to me.
Anyway, hope that helps you or gives you some ideas. Good luck!
EDIT: in case you care about that erroneous second error message you could use the slightly more complex:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T
type NextKey<T, K = keyof any> = [K] extends [undefined] ? undefined :
[keyof T | undefined] extends [K] ? keyof any : (keyof T | undefined)
declare function byPath<T0,
K1 extends NextKey<T0>, T1 extends IfKey<T0, K1>,
K2 extends NextKey<T1, K1>, T2 extends IfKey<T1, K2>,
K3 extends NextKey<T2, K2>, T3 extends IfKey<T2, K3>,
K4 extends NextKey<T3, K3>, T4 extends IfKey<T3, K4>,
K5 extends NextKey<T4, K4>, T5 extends IfKey<T4, K5>,
K6 extends NextKey<T5, K5>, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
which is pretty much the same except for when things go wrong with keys not matching what they're supposed to match.
Unfortunately, TypeScript doesn't currently allow arbitrary recursive type functions, which is what you want to iterate through a list of keys, drill down into an object type, and come out with the type of the nested property corresponding to the list of keys. You can do pieces of it, but it's a mess.
So you're going to have to pick some maximum level of nesting and write for that. Here's a possible type signature for your function which doesn't use overloads:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T;
declare function byPath<T0,
K1 extends keyof T0 | undefined, T1 extends IfKey<T0, K1>,
K2 extends keyof T1 | undefined, T2 extends IfKey<T1, K2>,
K3 extends keyof T2 | undefined, T3 extends IfKey<T2, K3>,
K4 extends keyof T3 | undefined, T4 extends IfKey<T3, K4>,
K5 extends keyof T4 | undefined, T5 extends IfKey<T4, K5>,
K6 extends keyof T5 | undefined, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
Note that you can easily extend that to more than six layers of nesting if you need to.
The way it works: there are two kinds of type parameters... key types (named K1
, K2
, etc), and object types (named T0
, T1
, etc). The state
property is of type T0
, and the path is a tuple with optional elements of the key types. Each key type is either a key of the previous object type, or it's undefined
. If the key is undefined, then the next object type is the same as the current object type; otherwise it's the type of the relevant property. So as soon as key types become and stay undefined
, the object types become and stay the last relevant property type... and the last object type (T6
above) is the return type of the function.
Let's do an example: if T0
is {a: {b: string}, c: {d: string}}
, then K1
must be one of 'a'
, 'd'
, or undefined
. Let's say that K1
is 'a'
. Then T1
is {b: string}
. Now K2
must be 'b'
or undefined
. Let's say that K2
is 'b'
. Then T2
is string
. Now K3
must be in keyof string
or undefined
. (So K3
could be "charAt"
, or any of the string
methods and properties). Let's say that K3
is undefined
. Then T3
is string
(since it is the same as T2
). And if all the rest of K4
, K5
, and K6
are undefined
, then T4
, T5
, and T6
are just string
. And the function returns T6
.
So if you do this call:
const ret = byPath({state: {a: {b: "hey"}, c: {d: "you"} }, path: ['a', 'b'] });
Then T0
will be inferred as {a: {b: string}, c: {d: string}
, K1
will be 'a'
, K2
will be 'b'
, and K3
through K6
will all be undefined
. Which is the example above, so T6
will be string
. And thus ret
will of type string
.
The above function signature should also yell at you if you enter a bad key:
const whoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['a', 'B'] });
// error! type "B" is not assignable to "b" | undefined: ----------------------> ~~~
That error makes sense, since B
is not valid. The following also yells at you:
const alsoWhoops = byPath({ state: { a: { b: "hey" }, c: { d: "you" } }, path: ['A', 'b'] });
// error! type "A" is not assignable to "a" | "c" | undefined: ---------------> ~~~
// also error! Type "b" is not assignable to "a" | "c" | undefined ?! -------------> ~~~
The first error is exactly what you'd expect; the second is a little weird, since "b"
is fine. But the compiler now has no idea what to expect for keyof T['A']
, so it is acting as if K1
were undefined
. If you fix the first error, the second will go away. There might be ways to alter the byPath()
signature to avoid this, but it seems minor to me.
Anyway, hope that helps you or gives you some ideas. Good luck!
EDIT: in case you care about that erroneous second error message you could use the slightly more complex:
type IfKey<T, K> = [K] extends [keyof T] ? T[K] : T
type NextKey<T, K = keyof any> = [K] extends [undefined] ? undefined :
[keyof T | undefined] extends [K] ? keyof any : (keyof T | undefined)
declare function byPath<T0,
K1 extends NextKey<T0>, T1 extends IfKey<T0, K1>,
K2 extends NextKey<T1, K1>, T2 extends IfKey<T1, K2>,
K3 extends NextKey<T2, K2>, T3 extends IfKey<T2, K3>,
K4 extends NextKey<T3, K3>, T4 extends IfKey<T3, K4>,
K5 extends NextKey<T4, K4>, T5 extends IfKey<T4, K5>,
K6 extends NextKey<T5, K5>, T6 extends IfKey<T5, K6>
>({ state, path }: { state: T0, path: [K1?, K2?, K3?, K4?, K5?, K6?] }): T6;
which is pretty much the same except for when things go wrong with keys not matching what they're supposed to match.
edited Nov 16 at 16:33
answered Nov 14 at 16:55
jcalz
21k21637
21k21637
Seems to not work on Partial<Record<T>> types. Any thoughts as to why?
– bingles
Nov 15 at 21:11
1
I ended up solving with type IfKey<T, K> = [K] extends [keyof T] ? Exclude<Required<T[K]>, undefined> : T; and then return type of T6 extends Required<infer U> ? U : T6
– bingles
Nov 15 at 22:04
add a comment |
Seems to not work on Partial<Record<T>> types. Any thoughts as to why?
– bingles
Nov 15 at 21:11
1
I ended up solving with type IfKey<T, K> = [K] extends [keyof T] ? Exclude<Required<T[K]>, undefined> : T; and then return type of T6 extends Required<infer U> ? U : T6
– bingles
Nov 15 at 22:04
Seems to not work on Partial<Record<T>> types. Any thoughts as to why?
– bingles
Nov 15 at 21:11
Seems to not work on Partial<Record<T>> types. Any thoughts as to why?
– bingles
Nov 15 at 21:11
1
1
I ended up solving with type IfKey<T, K> = [K] extends [keyof T] ? Exclude<Required<T[K]>, undefined> : T; and then return type of T6 extends Required<infer U> ? U : T6
– bingles
Nov 15 at 22:04
I ended up solving with type IfKey<T, K> = [K] extends [keyof T] ? Exclude<Required<T[K]>, undefined> : T; and then return type of T6 extends Required<infer U> ? U : T6
– bingles
Nov 15 at 22:04
add a comment |
Thanks for contributing an answer to Stack Overflow!
- Please be sure to answer the question. Provide details and share your research!
But avoid …
- Asking for help, clarification, or responding to other answers.
- Making statements based on opinion; back them up with references or personal experience.
To learn more, see our tips on writing great answers.
Some of your past answers have not been well-received, and you're in danger of being blocked from answering.
Please pay close attention to the following guidance:
- Please be sure to answer the question. Provide details and share your research!
But avoid …
- Asking for help, clarification, or responding to other answers.
- Making statements based on opinion; back them up with references or personal experience.
To learn more, see our tips on writing great answers.
Sign up or log in
StackExchange.ready(function () {
StackExchange.helpers.onClickDraftSave('#login-link');
});
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Required, but never shown
StackExchange.ready(
function () {
StackExchange.openid.initPostLogin('.new-post-login', 'https%3a%2f%2fstackoverflow.com%2fquestions%2f53293200%2ftypescript-get-deeply-nested-property-value-using-array%23new-answer', 'question_page');
}
);
Post as a guest
Required, but never shown
Sign up or log in
StackExchange.ready(function () {
StackExchange.helpers.onClickDraftSave('#login-link');
});
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Required, but never shown
Sign up or log in
StackExchange.ready(function () {
StackExchange.helpers.onClickDraftSave('#login-link');
});
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Required, but never shown
Sign up or log in
StackExchange.ready(function () {
StackExchange.helpers.onClickDraftSave('#login-link');
});
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Required, but never shown
Required, but never shown
Required, but never shown
Required, but never shown
Required, but never shown
Required, but never shown
Required, but never shown
Required, but never shown
Required, but never shown
I think this is an awesome question. Maybe it is possible to recursively traverse the path array during compile time by using spread types (
path: [KH, ...KT]
), but unfortunately this is not supported in TS 3.1. Nevertheless, I tried to come up with an untested POC; Maybe it will work if TS supports rest types in tuples.– Johannes Reuter
Nov 14 at 8:35