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?
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With