Skip to content

Typing for object deep paths #12290

Description

@wclr

Recently introduces keyof operator will work well for typing functions that accept properties directly on target type.

interface Some {
  a: number,
  b: string
}

type oneOfTheSomeKeys = keyof Some // restricts value to "a", "b"

What do you think, is it possible in theory to type deeply nested paths, so that for type:

interface Some {
  a: number,
  b: {
    c: number
    d: {
      e: number
    }
  }
}

so that it would be possible to restrict possible path values to:

["a"]
["b"]
["b", "c"]
["b", "d"]
["b", "d, "e"]

This actual for such methods like ramda's path:

R.path(['a', 'b'], {a: {b: 2}}); //=> 2
R.path(['a', 'b'], {c: {b: 2}}); //=> undefined

?

Activity

  1. HerringtonDarkholme commented on Nov 16, 2016

    @HerringtonDarkholme
    Contributor

    You can already do that with manual overloading.

    // overload more type parameter arity here
    function path<A extends string, B extends string, C extends string, D>(path: [A, B, C], d: {
        [K1 in A]: {[K2 in B]: {[K3 in C]: D}}
    }) {
        let ret = d
        for (let k of path) {
            ret = ret[k as string]
        }
    }
    
    path(['a', 'b', 'c'], {a: {b: 2}}) // error
    path(['a', 'b', 'c'], {a: {b: {c: 2}}})
  2. wclr commented on Nov 16, 2016

    @wclr
    Author

    Herrington Darkholme (@HerringtonDarkholme) oh that is really really nice, thanks for the example!

  3. aluanhaddad commented on Nov 16, 2016

    @aluanhaddad
    Contributor
  4. KiaraGrouwstra commented on Dec 11, 2016

    @KiaraGrouwstra
    Contributor

    Herrington Darkholme (@HerringtonDarkholme): thank you, that's pretty cool! I generated variants for different path lengths for Ramda, so if you'd like to use it, feel free. 😄
    I tried to see if I could make your definition work with arrays as well, but so far I haven't had much luck.
    Cases I'm hoping will work (or at least one of the two):

    path(['a', '0', 'c'], {a: [{c: 2}] })
    path(['a', 0, 'c'], {a: [{c: 2}] })

    I tried to see if adjusting the definition might help to make this work out.

    function path<A extends string, B extends number, C extends string, D>(path: [A, B, C], d: { // <- changed B to number
        [K1 in A]: {[K2 in B]: {[K3 in C]: D}}  // <- `K2 in B` now errors: "Type 'number' is not assignable to type 'string'"
    }) {
        // implementation detail
    }

    I suppose with {[K2 in B]: ...} this is being considered an object using a string-based index, making numerical indices (as used by arrays) fail. Perhaps this is implied by the in notation?

  5. wclr commented on Dec 11, 2016

    @wclr
    Author

    @tycho01

    To make this:

    path(['a', '0', 'c'], { a: [{ c: 2 }] })
    path(['a', 0, 'c'], { a: [{ c: 2 }] })

    work, typings should be something like that:

    // for level 1 array
    function path<A extends string, B extends string | number, C extends string, D>
      (path: [A, B, C],
      d: {[K1 in A]: {[K2 in C]: D}[]}
      ): D
    
    // for object
    function path<A extends string, B extends string | number, C extends string, D>
      (path: [A, B, C],
      d: {[K1 in A]: {[K2 in B]: {[K3 in C]: D}}}
      ): D
    
    
    function path(path: any, d: any): any {
      let ret = d
      for (let k of path) {
        ret = ret[k]
      }
    }

    The problem that it is not possible to do it withing a single signature for example like:

      d: {[K1 in A]: {[K2 in B]: {[K3 in C]: D}}} | {[K1 in A]: {[K2 in C]: D}[]}

    So if to still implement it there would be a need to have multiple combinations:

    A: O : O : O 
    A: A : O : O 
    ...
    O: A : O : O 
    ...
    

    You get it. But it is not impossible though.

  6. KiaraGrouwstra commented on Dec 11, 2016

    @KiaraGrouwstra
    Contributor

    Yeah, the exploding number of variants is a tad unfortunate, but for the moment should do if I can try to generate them.
    I guess technically the [] approach still presents an asymmetry between the objects and array versions, by constraining the arrays to be homogeneous lists, as opposed to say tuples, while the objects do not appear bound that way.
    That said, this progress is pretty great! I'll try to incorporate your idea for the Ramda function. :D

  7. wclr commented on Dec 11, 2016

    @wclr
    Author

    Yes it seem that is also case with tuples which makes typing issue unsolvable for general case.

  8. KiaraGrouwstra commented on Dec 11, 2016

    @KiaraGrouwstra
    Contributor

    Renamed in favour of (@whitecolor): made a commit for path lengths 1~7 (-> index.d.ts). Lotta code to get one extra test to pass, with still dozens others failing (not to mention the ones silently 'failing' with any types). Worth it!
    Can't wait to see what it'd look like if it is to handle tuples as well!

  9. wclr commented on Mar 24, 2017

    @wclr
    Author

    Herrington Darkholme (@HerringtonDarkholme) or somebody

    any advice how this can be typed, function that gets two key names and object and checks if first key is string, and second is number:

    function checkTypesOfKeys<
      KeyS extends string, KeyN extends string>
      (keyS: KeyS, keyN: KeyN,
      obj: {[K in KeyS]: string} & {[K in KeyN ]: number}): boolean // this doesn't work
    {
      return typeof (<any>obj)[keyS] === 'string'
        && typeof (<any>obj)[keyN] === 'number'
    }
    
    checkTypesOfKeys('str', 'num', {str: 'stringValue', num: 1}) // error
      Type '{ str: string; num: number; }' is not assignable to type '{ str: string; num: string; }'.
    
  10. KiaraGrouwstra commented on Mar 24, 2017

    @KiaraGrouwstra
    Contributor

    Renamed in favour of (@whitecolor): what if you make that KeyN into number?

    On that path definition I generated for Ramda based on the overloading suggestion given here, I've come to the conclusion this not only brings monstrous typings that are still inadequate (ignoring tuples), but also brings along performance issues, grinding TS to a halt after adding a significant number of overloads. Evidently, just following the original reduce logic is O(n), so I hope they'll consider my proposal to implement that as a solution...

  11. wclr commented on Mar 24, 2017

    @wclr
    Author

    KeyN in my question I assume to correspond to second key name (key name is a string anyway) argument.

    I hope they'll consider my proposal to implement that as a solution...

    In 10 years maybe =)

  12. wclr commented on Mar 24, 2017

    @wclr
    Author

    Herrington Darkholme (@HerringtonDarkholme) Thanks for suggestion)

    Any advice is it possible to solve more complicated case, to check if target object, contains props of orignal object with the same corresponding types?

    function compareTypesOfKeys<
      KeyS extends string, KeyN extends string>
      (original: { [K in (Keys & KeyN)]: any}):
      (target: {[K in KeyS]: string} & {[K in KeyN]: number}) => boolean // this doesn't work
    {
      return (target: any): boolean => {
        let isTheSameTypesOfKeys: boolean = true
        Object.keys(original).forEach((keyInOriginal) => {
          if (typeof target[keyInOriginal] !== typeof original[keyInOriginal]) {
            isTheSameTypesOfKeys = false
          }
        })
        return isTheSameTypesOfKeys
      }
    }
    
    compareTypesOfKeys({
      str: 'str',
      num: 1
    })({ str: 'stringValue', num: 1 })
  13. 36 remaining items

  14. lostpebble commented on Jul 20, 2019

    @lostpebble

    This is rather frustrating. I really thought there would be a way to do this, but seems like Typescript isn't ready yet.

    I've been trying out your examples Chet Corcos (@ccorcos) , the one earlier seemed to work quite well until I discovered that the inferred type for the deep key value is not being used properly in lower levels:

    interface DeepKeyOfArray<O> extends Array<string | number> {
      ["0"]: TKeyOf<O>;
      ["1"]?: this extends {
          ["0"]: infer K0;
        }
        ? K0 extends TKeyOf<O> ? TKeyOf<O[K0]> : never : never;
      ["2"]?: this extends {
          ["0"]: infer K0;
          ["1"]: infer K1;
        }
        ? K0 extends TKeyOf<O>
          ? (K1 extends TKeyOf<O[K0]> ? TKeyOf<O[K0][K1]> : never)
          : never
        : never;
      ["3"]?: this extends {
          ["0"]: infer K0;
          ["1"]: infer K1;
          ["2"]: infer K2;
        }
        ? K0 extends TKeyOf<O>
          ? K1 extends TKeyOf<O[K0]>
            ? K2 extends TKeyOf<O[K0][K1]>
              ? TKeyOf<O[K0][K1][K2]> : never : never : never : never;
    }
    
    interface IObj {
      count: boolean;
      tagsFirst: string[];
      deep: {
        color: string;
        tags: string[];
        deeper: {
          egg: boolean;
          more: {
            other: number;
          };
        };
      };
    }
    
    const path: DeepKeyOfArray<IObj> = ["count", "deeper"];

    That path at the bottom there doesn't throw any errors when its clear to see that count is as deep as it goes here, but it continues to allow any keys that are at the second level.

    Path referencing in JSON and JavaScript programming is quite a common use case... Would be really nice if we could have an easier way to deal with this.

  15. andriy-kudrya commented on Oct 12, 2019

    @andriy-kudrya

    Chet Corcos (@ccorcos) I've found a workaround to that error although the function signature becomes artificially more complicated

    function getPath<O, P extends ReadonlyArray<number | string>>(
    	o: O,
    	p: P
    ): () => PathType<O, P> {
    	return {} as any
    }

    also, I've replaced Array<number | string> with ReadonlyArray<number | string> for the sake of type inference

    example

  16. ScreamZ commented on Jan 5, 2020

    @ScreamZ

    So any way typescript support Dot Notation that's also problematic with things like MongoDB

  17. hsource commented on Apr 23, 2020

    @hsource

    While I liked the above functions, I thought the logic is a bit hard to follow. Here's a more verbose, but easy-to-read version that fully typechecks paths of <7 length:

    export type PathOfLength1<T, K1 extends keyof T> = [K1];
    export type PathOfLength2<T, K1 extends keyof T, K2 extends keyof T[K1]> = [
      K1,
      K2,
    ];
    export type PathOfLength3<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2]
    > = [K1, K2, K3];
    export type PathOfLength4<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2],
      K4 extends keyof T[K1][K2][K3]
    > = [K1, K2, K3, K4];
    export type PathOfLength5<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2],
      K4 extends keyof T[K1][K2][K3],
      K5 extends keyof T[K1][K2][K3][K4]
    > = [K1, K2, K3, K4, K5];
    export type PathOfLength6<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2],
      K4 extends keyof T[K1][K2][K3],
      K5 extends keyof T[K1][K2][K3][K4],
      K6 extends keyof T[K1][K2][K3][K4][K5]
    > = [K1, K2, K3, K4, K5, K6];
    export type PathOfLength7Plus<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2],
      K4 extends keyof T[K1][K2][K3],
      K5 extends keyof T[K1][K2][K3][K4],
      K6 extends keyof T[K1][K2][K3][K4][K5],
      K7 extends keyof T[K1][K2][K3][K4][K5][K6]
    > = [K1, K2, K3, K4, K5, K6, K7, ...(number | string)[]];
    
    /**
     * A function to validate that a path is valid for a variable of type T.
     * It simply returns the path itself, and doesn't do anything. The main value
     * is that it infers the types via function overloading.
     *
     * The best way to use this is to not explicitly state any types in the
     * generics and to pass a dummy value of the type the path should apply to
     * and a path.
     *
     * If the path is invalid, it won't typecheck. Unfortunately, the autocomplete
     * for `path` doens't work super well though.
     */
    export function validatePath<T, K1 extends keyof T>(
      _dummyValue: T,
      path: PathOfLength1<T, K1>,
    ): PathOfLength1<T, K1>;
    
    export function validatePath<T, K1 extends keyof T, K2 extends keyof T[K1]>(
      _dummyValue: T,
      path: PathOfLength2<T, K1, K2>,
    ): PathOfLength2<T, K1, K2>;
    
    export function validatePath<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2]
    >(
      _dummyValue: T,
      path: PathOfLength3<T, K1, K2, K3>,
    ): PathOfLength3<T, K1, K2, K3>;
    
    export function validatePath<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2],
      K4 extends keyof T[K1][K2][K3]
    >(
      _dummyValue: T,
      path: PathOfLength4<T, K1, K2, K3, K4>,
    ): PathOfLength4<T, K1, K2, K3, K4>;
    
    export function validatePath<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2],
      K4 extends keyof T[K1][K2][K3],
      K5 extends keyof T[K1][K2][K3][K4]
    >(
      _dummyValue: T,
      path: PathOfLength5<T, K1, K2, K3, K4, K5>,
    ): PathOfLength5<T, K1, K2, K3, K4, K5>;
    
    export function validatePath<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2],
      K4 extends keyof T[K1][K2][K3],
      K5 extends keyof T[K1][K2][K3][K4],
      K6 extends keyof T[K1][K2][K3][K4][K5]
    >(
      _dummyValue: T,
      path: PathOfLength6<T, K1, K2, K3, K4, K5, K6>,
    ): PathOfLength6<T, K1, K2, K3, K4, K5, K6>;
    
    export function validatePath<
      T,
      K1 extends keyof T,
      K2 extends keyof T[K1],
      K3 extends keyof T[K1][K2],
      K4 extends keyof T[K1][K2][K3],
      K5 extends keyof T[K1][K2][K3][K4],
      K6 extends keyof T[K1][K2][K3][K4][K5],
      K7 extends keyof T[K1][K2][K3][K4][K5][K6]
    >(
      _dummyValue: T,
      path: PathOfLength7Plus<T, K1, K2, K3, K4, K5, K6, K7>,
    ): PathOfLength7Plus<T, K1, K2, K3, K4, K5, K6, K7>;
    
    export function validatePath<T>(_dummyValue: T, path: unknown): unknown {
      return path;
    }
  18. lolmaus commented on Apr 24, 2020

    @lolmaus

    This looks nice: https://github.com/bsalex/typed-path/

    The best part about it is that it allows using with any pre-existing methods that accept property paths.

    But the syntax is so verbose and hard to understand that it defeats the purpose.

    Instead of

    foo.mapBy('a.b.c')
    

    I'd rather do

    foo.map(e => e.a.b.c)
    

    than

    foo.mapBy(tp<Foo>().a.b.c.toString())
    
  19. agalazis commented on Apr 29, 2020

    @agalazis
  20. lostpebble commented on Jun 5, 2020

    @lostpebble

    Thanks all for the suggestions. They're unfortunately all so hacky and verbose, and sometimes unusable in certain situations. I've tried implementing them, and its as if one day they work and the next day they don't (maybe TypeScript version changes etc.). They also very complex and super difficult to debug.

    I really just wish the TypeScript team would add an ObjectKeyPath<T> utility function, which is an array of variable length but each item conforms to the deep key path of object T.

    This kind of functionality is so important in JavaScript and JSON, especially for JSON Patch kind of functionality.

    immer, one of my favorite JavaScript libraries, implements "patches", a kind of diff between object operations, which makes use of object deep paths to show which parts have changed. I'd really like to take advantage of this functionality in my own libraries and have it nicely typed for the user.

    There are many good uses for such functionality. Big one being smart undo / redo of changes to an object structure.

    I wish the TypeScript team would care a lil more about this type. It seems to be quite a non-prioritized thing sadly...

  21. ccorcos commented on Jun 5, 2020

    @ccorcos

    I totally much agree. Seems like this could be implemented internally with much better success. Any thoughts? Mohamed Hegazy (@mhegazy) Ryan Cavanaugh (@RyanCavanaugh)

  22. jameslaneconkling commented on Jun 5, 2020

    @jameslaneconkling

    FWIW, ts-toolbelt's Object.Path type has been very effective at properly typing most non-trivial use cases I've thrown at it:

    import { O } from 'ts-toolbelt'
    
    type T = {
      a: {
        b: { c: number } | { c: string },
        d: { e: boolean }[]
      }
    }
    
    type C = O.Path<T, ['a', 'b', 'c']> // type C = string | number
    type E = O.Path<T, ['a', 'd', 0, 'e']> // type E = boolean
    type F = O.Path<T, ['a', 'b', 'c', 'f']> // type F = never
    type G = O.Path<T, ['g']> // type G = never

    An approach to typing a path function (with the unfortunate caveat that the function is variadic, rather than taking an array as the second argument) could look like:

    declare const path: <T extends object, P extends (string | number)[]>(value: T, ...path: P) => O.Path<T, P>
    declare const t: T
    const c = path(t, 'a', 'b', 'c') // c: string | number

    Not a complete solution, but it's been more reliable than other approaches I've taken.

  23. hrsh7th commented on Sep 1, 2020

    @hrsh7th
  24. fxlrnrpt commented on Dec 25, 2020

    @fxlrnrpt

    Guys, I think I made it work with the new recursive types

    type ExtractObj<S extends object, K> = K extends keyof S ? S[K] : never
    
    type Path<S extends object, T extends readonly unknown[]> =
        T extends readonly [infer T0, ...infer TR]
        ? TR extends []
            ? ExtractObj<S, T0> extends never
                ? readonly []
                : readonly [T0]
            : ExtractObj<S, T0> extends object
                ? readonly [T0, ...Path<ExtractObj<S, T0>, TR>]
                : ExtractObj<S, T0> extends never
                    ? readonly []
                    : readonly [T0]
        : readonly []
    
    class Store<S extends object> {
        subscribe<T extends readonly unknown[]>(path: T extends Path<S, T> ? T : never) {}
    }
    
    type StoreExample = {
        prop1: {
            nested1: {
                nested1a: string
                nested1b: number
            }
        }
        prop2: {
            nested20: boolean
            nested21: {
                nested21a: number
            }
        }
    }
    
    const store = new Store<StoreExample>()
    
    // Valid
    store.subscribe(['prop1'] as const)
    store.subscribe(['prop1', 'nested1', 'nested1a'] as const)
    store.subscribe(['prop2', 'nested20'] as const)
    store.subscribe(['prop2', 'nested21', 'nested21a'] as const)
    
    // Invalid
    store.subscribe(['prop3'] as const)
    store.subscribe(['prop1', 'nested20'] as const)
    store.subscribe(['prop1', 'nested1', 'nested1a', 'something'] as const)

    Playground

  25. noahseger commented on Dec 28, 2020

    @noahseger

    aigoncharov thanks for this! It appears you can also remove the need for as const with this change to the signature:

    - subscribe<T extends readonly unknown[]>(path: T extends Path<S, T> ? T : never) {}
    + subscribe<T extends readonly [keyof S, ...unknown[]]>(path: T extends Path<S, T> ? T : never) {}

    Updated Playground

  26. franz101 commented on Oct 2, 2021

    @franz101
  27. mqliutie commented on Nov 28, 2022

    @mqliutie

    Guys, I think I made it work with the new recursive types

    type ExtractObj<S extends object, K> = K extends keyof S ? S[K] : never
    
    type Path<S extends object, T extends readonly unknown[]> =
        T extends readonly [infer T0, ...infer TR]
        ? TR extends []
            ? ExtractObj<S, T0> extends never
                ? readonly []
                : readonly [T0]
            : ExtractObj<S, T0> extends object
                ? readonly [T0, ...Path<ExtractObj<S, T0>, TR>]
                : ExtractObj<S, T0> extends never
                    ? readonly []
                    : readonly [T0]
        : readonly []
    
    class Store<S extends object> {
        subscribe<T extends readonly unknown[]>(path: T extends Path<S, T> ? T : never) {}
    }
    
    type StoreExample = {
        prop1: {
            nested1: {
                nested1a: string
                nested1b: number
            }
        }
        prop2: {
            nested20: boolean
            nested21: {
                nested21a: number
            }
        }
    }
    
    const store = new Store<StoreExample>()
    
    // Valid
    store.subscribe(['prop1'] as const)
    store.subscribe(['prop1', 'nested1', 'nested1a'] as const)
    store.subscribe(['prop2', 'nested20'] as const)
    store.subscribe(['prop2', 'nested21', 'nested21a'] as const)
    
    // Invalid
    store.subscribe(['prop3'] as const)
    store.subscribe(['prop1', 'nested20'] as const)
    store.subscribe(['prop1', 'nested1', 'nested1a', 'something'] as const)

    Playground

    niubility

  28. locked as resolved and limited conversation to collaborators on Oct 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    QuestionAn issue which isn't directly actionable in code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions