Home > Net >  A recursive type to explore a JSON-like object
A recursive type to explore a JSON-like object

Time:09-22

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']>(...);

Playground


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)

Playground

  • Related