Lodash has the function get()
, which extracts a value from a complex object:
const object = { 'a': [{ 'b': { 'c': 3 } }] };
const n: 3 = _.get(object, 'a[0].b.c');
Lodash types get
as:
type PropertyName = string | number | symbol;
type PropertyPath = Many<PropertyName>;
get(object: any, path: PropertyPath, defaultValue?: any): any;
Which is, in my opinion, somewhat weak. Monocle-ts solves a similar problem by just listing the first five or so possibilities:
export interface LensFromPath<S> {
<
K1 extends keyof S,
K2 extends keyof S[K1],
K3 extends keyof S[K1][K2],
K4 extends keyof S[K1][K2][K3],
K5 extends keyof S[K1][K2][K3][K4]
>(
path: [K1, K2, K3, K4, K5]
): Lens<S, S[K1][K2][K3][K4][K5]>
<K1 extends keyof S, K2 extends keyof S[K1], K3 extends keyof S[K1][K2], K4 extends keyof S[K1][K2][K3]>(
path: [K1, K2, K3, K4]
): Lens<S, S[K1][K2][K3][K4]>
<K1 extends keyof S, K2 extends keyof S[K1], K3 extends keyof S[K1][K2]>(path: [K1, K2, K3]): Lens<S, S[K1][K2][K3]>
<K1 extends keyof S, K2 extends keyof S[K1]>(path: [K1, K2]): Lens<S, S[K1][K2]>
<K1 extends keyof S>(path: [K1]): Lens<S, S[K1]>
}
Which is great as far as it goes, but it only goes so far. Is there not a cleaner way?
CodePudding user response:
I found this imperfect solution:
type TypeAt<S, P> = P extends readonly [any, ...any] ?
(P extends readonly [(infer K extends keyof S), ...(infer Rest)] ?
TypeAt<S[K], Rest> : never) : S;
declare function get<S,P>(object: S, path: P): TypeAt<S, P>;
const object = { 'a': [{ 'b': { 'c': 3 } }] };
const n: number = get(object, ['a', 0, 'b', 'c'] as const)
The imperfection, to my mind, is that as const
. Is there some way to cue the compiler in to not widen a literal object?
CodePudding user response:
Building on your existing answer, we can use this extremely useful type to narrow the type of path
without needing as const
:
type Narrow<T> =
| (T extends infer U ? U : never)
| Extract<T, number | string | boolean | bigint | symbol | null | undefined | []>
| ([T] extends [[]] ? [] : { [K in keyof T]: Narrow<T[K]> });
declare function get<S, P>(object: S, path: Narrow<P>): TypeAt<S, P>;
Then when you call it, P
will be the inferred literal tuple:
const n: number = get(object, ['a', 0, 'b', 'c']) // get<{ ... }, ['a', 0, 'b', 'c']>(...);
Going two steps further, we can also validate the keys (with somewhat helpful error messages):
type ValidPath<P, O> = P extends [infer K, ...infer Rest] ? K extends keyof O ? [K, ...ValidPath<Rest, O[K]>] : [{ error: `Key '${K & string}' doesn't exist`, on: O }] : P;
declare function get<S,P>(object: S, path: ValidPath<Narrow<P>, S>): TypeAt<S, P>;
So when you make a mistake, for example, using "0"
instead of 0
:
const n: number = get(object, ['a', "0", 'b', 'c'])
You will get a friendly reminder that it doesn't exist:
Type 'string' is not assignable to type '{ error: "Key '0' doesn't exist"; on: { b: { c: number; }; }[]; }'.(2322)