Search code examples
typescripttyping

A recursive type to explore a JSON-like object


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?


Solution

  • 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