115

So I would like to find a way to have all the keys of a nested object.

I have a generic type that take a type in parameter. My goal is to get all the keys of the given type.

The following code work well in this case. But when I start using a nested object it's different.

type SimpleObjectType = {
  a: string;
  b: string;
};

// works well for a simple object
type MyGenericType<T extends object> = {
  keys: Array<keyof T>;
};

const test: MyGenericType<SimpleObjectType> = {
  keys: ['a'];
}

Here is what I want to achieve but it doesn't work.

type NestedObjectType = {
  a: string;
  b: string;
  nest: {
    c: string;
  };
  otherNest: {
    c: string;
  };
};

type MyGenericType<T extends object> = {
  keys: Array<keyof T>;
};

// won't works => Type 'string' is not assignable to type 'a' | 'b' | 'nest' | 'otherNest'
const test: MyGenericType<NestedObjectType> = {
  keys: ['a', 'nest.c'];
}

So what can I do, without using function, to be able to give this kind of keys to test ?

CalibanAngel
  • 1,331
  • 2
  • 9
  • 8
  • 1
    there is no way to concat strig literals like you want to "nest.c", but you can have "c" in your keys if this is enaught – Juraj Kocan Oct 17 '19 at 14:49
  • @JurajKocan Well, as you can see, `c` is present in both `nest` and `otherNest`. So i don't think it's enougth. What would be your solution ? – CalibanAngel Oct 17 '19 at 14:54
  • 1
    You could maybe represent paths as tuples like `{keys: [["a"], ["nest", "c"]]}` (and here I'd probably say `paths` instead of `keys`). If `Paths` were `[] | ["a"] | ["b"] | ["nest"] | ["nest", "c"] | ["otherNest"] | ["otherNest", "c"]` (or maybe `["a"] | ["b"] | ["nest", "c"] | ["otherNest", "c"]` if you only care about leaf nodes), would that work? Even if so it's a bit tricky since the obvious implementation of `Paths` is recursive in a way not exactly supported. Also, what would you want `Paths` for `type Tree = {l: Tree, r: Tree}` to be? – jcalz Oct 17 '19 at 15:15
  • @jcalz I think it would perfectly work. Do you know if there is already a native built-in or library implementing `Paths` ? – CalibanAngel Oct 17 '19 at 15:30
  • Maybe related: https://stackoverflow.com/questions/58361316/how-to-merge-a-map-of-types-into-a-single-flat-type-in-typescript – Paleo Oct 17 '19 at 15:46

14 Answers14

221

UPDATE for TS4.1 It is now possible to concatenate string literals at the type level, using template literal types as implemented in microsoft/TypeScript#40336. The below implementation can be tweaked to use this instead of something like Cons (which itself can be implemented using variadic tuple types as introduced in TypeScript 4.0):

type Join<K, P> = K extends string | number ?
    P extends string | number ?
    `${K}${"" extends P ? "" : "."}${P}`
    : never : never;

Here Join concatenates two strings with a dot in the middle, unless the last string is empty. So Join<"a","b.c"> is "a.b.c" while Join<"a",""> is "a".

Then Paths and Leaves become:

type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: K extends string | number ?
        `${K}` | Join<K, Paths<T[K], Prev[D]>>
        : never
    }[keyof T] : ""

type Leaves<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: Join<K, Leaves<T[K], Prev[D]>> }[keyof T] : "";

And the other types fall out of it:

type NestedObjectPaths = Paths<NestedObjectType>;
// type NestedObjectPaths = "a" | "b" | "nest" | "otherNest" | "nest.c" | "otherNest.c"
type NestedObjectLeaves = Leaves<NestedObjectType>
// type NestedObjectLeaves = "a" | "b" | "nest.c" | "otherNest.c"

and

type MyGenericType<T extends object> = {
    keys: Array<Paths<T>>;
};

const test: MyGenericType<NestedObjectType> = {
    keys: ["a", "nest.c"]
}

The rest of the answer is basically the same. Recursive conditional types (as implemented in microsoft/TypeScript#40002) will be supported in TS4.1 also, but recursion limits still apply so you'd have a problem with tree-like structures without a depth limiter like Prev.

PLEASE NOTE that this will make dotted paths out of non-dottable keys, like {foo: [{"bar-baz": 1}]} might produce foo.0.bar-baz. So be careful to avoid keys like that, or rewrite the above to exclude them.

ALSO PLEASE NOTE: these recursive types are inherently "tricky" and tend to make the compiler unhappy if modified slightly. If you're not lucky you will see errors like "type instantiation is excessively deep", and if you're very unlucky you will see the compiler eat up all your CPU and never complete type checking. I'm not sure what to say about this kind of problem in general... just that such things are sometimes more trouble than they're worth.

Playground link to code



PRE-TS4.1 ANSWER:

As mentioned, it is not currently possible to concatenate string literals at the type level. There have been suggestions which might allow this, such as a suggestion to allow augmenting keys during mapped types and a suggestion to validate string literals via regular expression, but for now this is not possible.

Instead of representing paths as dotted strings, you can represent them as tuples of string literals. So "a" becomes ["a"], and "nest.c" becomes ["nest", "c"]. At runtime it's easy enough to convert between these types via split() and join() methods.


So you might want something like Paths<T> that returns a union of all the paths for a given type T, or possibly Leaves<T> which is just those elements of Paths<T> which point to non-object types themselves. There is no built-in support for such a type; the ts-toolbelt library has this, but since I can't use that library in the Playground, I will roll my own here.

Be warned: Paths and Leaves are inherently recursive in a way that can be very taxing on the compiler. And recursive types of the sort needed for this are not officially supported in TypeScript either. What I will present below is recursive in this iffy/not-really-supported way, but I try to provide a way for you to specify a maximum recursion depth.

Here we go:

type Cons<H, T> = T extends readonly any[] ?
    ((h: H, ...t: T) => void) extends ((...r: infer R) => void) ? R : never
    : never;

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]

type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: [K] | (Paths<T[K], Prev[D]> extends infer P ?
        P extends [] ? never : Cons<K, P> : never
    ) }[keyof T]
    : [];


type Leaves<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: Cons<K, Leaves<T[K], Prev[D]>> }[keyof T]
    : [];

The intent of Cons<H, T> is to take any type H and a tuple-type T and produce a new tuple with H prepended onto T. So Cons<1, [2,3,4]> should be [1,2,3,4]. The implementation uses rest/spread tuples. We'll need this to build up paths.

The type Prev is a long tuple that you can use to get the previous number (up to a max value). So Prev[10] is 9, and Prev[1] is 0. We'll need this to limit the recursion as we proceed deeper into the object tree.

Finally, Paths<T, D> and Leaves<T, D> are implemented by walking down into each object type T and collecting keys, and Consing them onto the Paths and Leaves of the properties at those keys. The difference between them is that Paths also includes the subpaths in the union directly. By default, the depth parameter D is 10, and at each step down we reduce D by one until we try to go past 0, at which point we stop recursing.


Okay, let's test it:

type NestedObjectPaths = Paths<NestedObjectType>;
// type NestedObjectPaths = [] | ["a"] | ["b"] | ["c"] | 
// ["nest"] | ["nest", "c"] | ["otherNest"] | ["otherNest", "c"]
type NestedObjectLeaves = Leaves<NestedObjectType>
// type NestedObjectLeaves = ["a"] | ["b"] | ["nest", "c"] | ["otherNest", "c"]

And to see the depth-limiting usefulness, imagine we have a tree type like this:

interface Tree {
    left: Tree,
    right: Tree,
    data: string
}

Well, Leaves<Tree> is, uh, big:

type TreeLeaves = Leaves<Tree>; // sorry, compiler ⌛
// type TreeLeaves = ["data"] | ["left", "data"] | ["right", "data"] | 
// ["left", "left", "data"] | ["left", "right", "data"] | 
// ["right", "left", "data"] | ["right", "right", "data"] | 
// ["left", "left", "left", "data"] | ... 2038 more ... | [...]

and it takes a long time for the compiler to generate it and your editor's performance will suddenly get very very poor. Let's limit it to something more manageable:

type TreeLeaves = Leaves<Tree, 3>;
// type TreeLeaves2 = ["data"] | ["left", "data"] | ["right", "data"] |
// ["left", "left", "data"] | ["left", "right", "data"] | 
// ["right", "left", "data"] | ["right", "right", "data"]

That forces the compiler to stop looking at a depth of 3, so all your paths are at most of length 3.


So, that works. It's quite likely that ts-toolbelt or some other implementation might take more care not to cause the compiler to have a heart attack. So I wouldn't necessarily say you should use this in your production code without significant testing.

But anyway here's your desired type, assuming you have and want Paths:

type MyGenericType<T extends object> = {
    keys: Array<Paths<T>>;
};

const test: MyGenericType<NestedObjectType> = {
    keys: [['a'], ['nest', 'c']]
}

Link to code

starball
  • 20,030
  • 7
  • 43
  • 238
jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Amazing answer, it works even better than the suggested `Paths` from ts-toolbelt that failed on union types. – jeben May 06 '20 at 11:17
  • I tried to "reverse" this type to get the value of a leaf from a tuple (similar to https://pirix-gh.github.io/ts-toolbelt/modules/_object_path_.html), but I could wrap my head around it. Is there a way to have this : `type Leave = type at T[...U]` (sort of) ? – jeben May 06 '20 at 11:29
  • "it works even better"... well, maybe? I think that the above definition of `Paths`/`Leaves` make it really easy to blow out the compiler. If you write `type Idx> = ...`, which might be useful to define your "reverse" type, it will bog down the compiler even with the depth limiter `D`. – jcalz May 06 '20 at 13:32
  • 2
    In any case if I do provide a reverse type it would have to be in a new question, since a comment section isn't a great place to present significantly different code and explanations. – jcalz May 06 '20 at 15:37
  • 1
    @jcalz thank you for your answer, I actually tried and here is my question : https://stackoverflow.com/questions/61644053/how-to-retrieve-a-type-from-a-nested-property-using-a-path-tuple – jeben May 06 '20 at 19:47
  • `Paths` seems to break if the type contains a property that's an array, eg `a: string[];`. To fix that I added `T extends Array ? never :` to the beginning of the type (directly after the `=`). – Qtax Sep 23 '20 at 08:46
  • 6
    I was getting `Type instantiation is excessively deep and possibly infinite` when using `Paths` some times (even with a low depth of 3). A workaround that worked for me was to use `infer`, by replacing `Join>` with `(Paths extends infer R ? Join : never)`. – Qtax Sep 23 '20 at 09:27
  • @jcalz Hi! Good answer, thanks! I customized your `Paths` type definition and renamed it `MongoDotPaths` in order to work with MongoDB's [dot notation](https://docs.mongodb.com/manual/reference/glossary/#term-dot-notation)-like object paths. [Here](http://bitly.ws/9SN5) is the link to its playground. _It uses **TypeScript 4.1**_ – xeptore Sep 29 '20 at 12:18
  • unfortunately in my case typescript compilation fails with ``` (node:560117) UnhandledPromiseRejectionWarning: RangeError: Maximum call stack size exceeded at getBaseConstraintOfType (/project/node_modules/typescript/lib/typescript.js:53240:41) at getTypeFacts (/project/node_modules/typescript/lib/typescript.js:62711:37) at getTypeFacts (/project/node_modules/typescript/lib/typescript.js:62711:24) ``` – Martin Ždila Dec 09 '20 at 09:43
  • 3
    @MartinŽdila and @Qtax, I also got that problem. If you don't need to go potentially 20 objects deep, you can shorten the range of `Prev`. For my project, I know it'll only go up to at most 2 objects deep, so I shortened mine to be like this instead: `export type Prev = [never, 0, 1, 2, 3, 4, ...0[]];`. Minor edit: He talks about the purpose of `Prev` right where he says "The rest of the answer is basically the same". – Michael Ziluck Jan 29 '21 at 19:23
  • In TS4.1 Paths<> return any but in TS4.2Beta it works nice. – karianpour Feb 27 '21 at 22:28
  • @karianpour Can you demonstrate that with a [mcve]? I cannot reproduce. – jcalz Feb 28 '21 at 01:59
  • @jcalz someome already did and it is in typescriptland play but I cannot send the url as it is too long, but the stackbliz url : https://stackblitz.com/edit/qh7iuc--run?file=index.ts, if you choose TS4.1.5 Paths is any, but with TS4.2 is is as expected – karianpour Mar 01 '21 at 06:24
  • @karianpour "Yikes! The page you requested couldn't be found." – jcalz Mar 01 '21 at 15:44
  • @jcalz sorry, please check line 35 of these links: TS4.2: https://cutt.ly/alC08v2, and TS4.1.5 https://cutt.ly/hlC2zxD – karianpour Mar 01 '21 at 23:39
  • Okay, I think 4.2 must bail out in a different way from 4.1. If you need to deal with arrays for 4.1 (it's arrays that seem to be doing it) then you can write it [this way](https://tsplay.dev/mbkZEW) instead. – jcalz Mar 02 '21 at 20:41
  • @jcalz Could `Type instantiation is excessively deep and possibly infinite` errors be avoided by setting the default value of the `D` generic in Paths to a smaller value? I'm trying to fully understand the link between the Prev type and the default value of D. – Adrian Pop Jun 17 '21 at 10:25
  • @AdrianPop, if you are getting that problem, change the value of `D extends number = 10` to be `D extends number = 5` in both places. This limits your depth but prevents those problems if you know you aren't ever needing to refer to keys that deep. – Michael Ziluck Jul 19 '21 at 19:31
  • 1
    @MichaelZiluck I ended up having `type Prev = [never, 0, 1, 2, 3, 4, ...0[]];` and `D = 3` by the way. – Adrian Pop Jul 20 '21 at 08:27
  • 1
    Why did you use `[D] extends [never]` instead of `D extends never`? – nicusor Dec 19 '21 at 15:58
  • I'd have to review the code carefully to be sure. `D extends never` would be a *distributive* conditional type and I assume I didn't want that behavior. – jcalz Dec 19 '21 at 16:51
  • This is awesome! The only thing I've seen it trip up on is for arrays of objects. Take this example: `type PatchValues = { texts: [{ text_editor: string}] }` `type PatchValueLeaves = Leaves` yields `"texts.1" | "texts.0.text_editor"`. Not sure where the "texts.1" part is coming from. If anyone has been able to tweak it to avoid that use-case lmk! – Grey Vugrin Mar 03 '22 at 00:57
  • Thanks for a great and thorough answer. I noticed that the `Paths` and `Leaves` do not support object values that may be a function. Instead of using `T extends object`, it would be more accurate to use `T extends Record`, which would account for a more thorough value type. https://cutt.ly/HCyr8Zz – tatemz Sep 01 '22 at 03:32
  • 2
    "... Cons (which itself can be implemented using variadic tuple types as introduced in TypeScript 4.0)" - for anyone who wonders how to do so: Using TS 4.0+, you can remove the utility type `Cons` and replace any `Cons` by `[H, ...T]` – YetAnotherFrank Dec 07 '22 at 14:17
  • I would also like to add this change with `unknown` type condition. ```typescript type Paths = T extends unknown ? string[] : [D] extends [never] ? never : T extends object ? { [K in keyof T]-?: [K] | (Paths extends infer P ? P extends [] ? never : Cons : never ) }[keyof T] : [];``` – hazer_hazer May 24 '23 at 12:10
  • This is brilliant, thanks! TypeScript seems to throw up its hands when it encounters a generic constraint though… `function fnTest (obj: T) { const leaf: Leaves = 'a' // <-- TS ERROR }` (https://shorturl.at/crKX5) – Merott Jun 13 '23 at 12:05
36

A recursive type function using conditional types, template literal strings, mapped types and index access types based on @jcalz's answer and can be verified with this ts playground example

generates a union type of properties including nested with dot notation

type DotPrefix<T extends string> = T extends "" ? "" : `.${T}`

type DotNestedKeys<T> = (T extends object ?
    { [K in Exclude<keyof T, symbol>]: `${K}${DotPrefix<DotNestedKeys<T[K]>>}` }[Exclude<keyof T, symbol>]
    : "") extends infer D ? Extract<D, string> : never;

/* testing */

type NestedObjectType = {
    a: string
    b: string
    nest: {
        c: string;
    }
    otherNest: {
        c: string;
    }
}

type NestedObjectKeys = DotNestedKeys<NestedObjectType>
// type NestedObjectKeys = "a" | "b" | "nest.c" | "otherNest.c"

const test2: Array<NestedObjectKeys> = ["a", "b", "nest.c", "otherNest.c"]

this is also useful when using document databases like mongodb or firebase firestore that enables to set single nested properties using dot notation

With mongodb

db.collection("products").update(
   { _id: 100 },
   { $set: { "details.make": "zzz" } }
)

With firebase

db.collection("users").doc("frank").update({
   "age": 13,
   "favorites.color": "Red"
})

This update object can be created using this type

then typescript will guide you, just add the properties you need

export type DocumentUpdate<T> = Partial<{ [key in DotNestedKeys<T>]: any & T}> & Partial<T>

enter image description here

you can also update the do nested properties generator to avoid showing nested properties arrays, dates ...

type DotNestedKeys<T> =
T extends (ObjectId | Date | Function | Array<any>) ? "" :
(T extends object ?
    { [K in Exclude<keyof T, symbol>]: `${K}${DotPrefix<DotNestedKeys<T[K]>>}` }[Exclude<keyof T, symbol>]
    : "") extends infer D ? Extract<D, string> : never;
mindlid
  • 1,679
  • 14
  • 17
  • Hi, great solution! Is there a way to only show direct child nodes instead of all the possible paths? Ex: a, b, nest, otherNest, instead of nest, nest.c, ect. So nest.c would only appear if the user types "nest". – DoneDeal0 Aug 19 '21 at 09:04
  • @DoneDeal0 yes, in that case you don't have to do anything but passing the original type. – mindlid Aug 19 '21 at 22:54
  • I've tried to type the parameter of the function with `typeof objectName`, but it doesn't work. Maybe I'm doing something wrong? https://codesandbox.io/s/aged-darkness-s6kmx?file=/src/App.tsx – DoneDeal0 Aug 20 '21 at 07:57
  • Just a last question, is it possible to allow unknown strings in your type while keeping the autocomplete? If I wrap the `NestedObjectKeys` with `Partial<{ [key in KeyPath]: any & T}> & string` - as suggested in your example - the autocomplete is not available anymore. However, without this additional typing, typescript refuses any string that do not match the original object structure. It is thus not compatible with dynamic typing (ex: `"PREFIX." + dynamicKey` would return an error). I've made a sandbox with your code: https://codesandbox.io/s/modest-robinson-8b1cj?file=/src/App.tsx . – DoneDeal0 Aug 27 '21 at 10:14
  • If you need `nest` not only `nest.a` and `nest.b` in result, you can change `DotNestedKeys` like this: type DotNestedKeys = (T extends object ? { [K in Exclude]: `${K}${DotPrefix> | K}` }[Exclude]: "") extends infer D ? Extract : never; – 0x6368656174 Nov 10 '21 at 07:05
  • That seems to be what I'm looking for. Should I be worried that there is no depth limiter? – dewey Feb 09 '22 at 15:02
  • And is there a way to also check the datatype of the `DotNestedKeys`. I need to only accept. I tried it with `colorRange?: DotNestedKeys extends number ? Array : never;` but this is always undefined/never – dewey Feb 09 '22 at 15:15
  • Very neat @saulpalv. I'm trying to modify the solution so you also get the types for each "path" but I'm lacking the skills. Can you see an easy way to do that? Also; can you add a few comments about `extends infer D ? Extract : never;`? I don't understand what that does really – mr.bjerre Mar 28 '22 at 07:34
  • 1
    If your object has circular references this fails. I wonder if it's possible to put a limit on the amount of depth this allows – Gisheri Apr 19 '22 at 21:00
16

I came across a similar problem, and granted, the above answer is pretty amazing. But for me, it goes a bit over the top and as mentioned is quite taxing on the compiler.

While not as elegant, but much simpler to read, I propose the followingtype for generating a Path-like tuple:

type PathTree<T> = {
    [P in keyof T]-?: T[P] extends object
        ? [P] | [P, ...Path<T[P]>]
        : [P];
};

type Path<T> = PathTree<T>[keyof T];

A major drawback is, that this type cannot deal with self-referncing types like Tree from @jcalz answer:

interface Tree {
  left: Tree,
  right: Tree,
  data: string
};

type TreePath = Path<Tree>;
// Type of property 'left' circularly references itself in mapped type 'PathTree<Tree>'.ts(2615)
// Type of property 'right' circularly references itself in mapped type 'PathTree<Tree>'.ts(2615)

But for other types it seems to do well:

interface OtherTree {
  nested: {
    props: {
      a: string,
      b: string,
    }
    d: number,
  }
  e: string
};

type OtherTreePath = Path<OtherTree>;
// ["nested"] | ["nested", "props"] | ["nested", "props", "a"]
// | ["nested", "props", "b"] | ["nested", "d"] | ["e"]

If you want to force only referencing leaf nodes, you can remove the [P] | in the PathTree type:

type LeafPathTree<T> = {
    [P in keyof T]-?: T[P] extends object 
        ? [P, ...LeafPath<T[P]>]
        : [P];
};
type LeafPath<T> = LeafPathTree<T>[keyof T];

type OtherPath = Path<OtherTree>;
// ["nested", "props", "a"] | ["nested", "props", "b"] | ["nested", "d"] | ["e"]

For some more complex objects the type unfortunately seems to default to [...any[]].


When you need dot-syntax similar to @Alonso's answer, you can map the tuple to template string types:

// Yes, not pretty, but not much you can do about it at the moment
// Supports up to depth 10, more can be added if needed
type Join<T extends (string | number)[], D extends string = '.'> =
  T extends { length: 1 } ? `${T[0]}`
  : T extends { length: 2 } ? `${T[0]}${D}${T[1]}`
  : T extends { length: 3 } ? `${T[0]}${D}${T[1]}${D}${T[2]}`
  : T extends { length: 4 } ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}`
  : T extends { length: 5 } ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}`
  : T extends { length: 6 } ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}`
  : T extends { length: 7 } ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}${D}${T[6]}`
  : T extends { length: 8 } ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}${D}${T[6]}${D}${T[7]}`
  : T extends { length: 9 } ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}${D}${T[6]}${D}${T[7]}${D}${T[8]}`
  : `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}${D}${T[6]}${D}${T[7]}${D}${T[8]}${D}${T[9]}`;

type DotTreePath = Join<OtherTreePath>;
// "nested" | "e" | "nested.props" | "nested.props.a" | "nested.props.b" | "nested.d"

Link to TS playground

Aram Becker
  • 2,026
  • 1
  • 20
  • 32
  • This still seems too slow for a production environment. :( – christo8989 Mar 22 '22 at 17:02
  • 1
    @christo8989 Why would you use Typescript in a production env? – Nader Zouaoui Jun 02 '22 at 19:04
  • 1
    I think they meant "production environment" in terms of a large scale, actively used project that runs in a production environment. – Aram Becker Jun 06 '22 at 16:13
  • 2
    This solution is slow because it suffers from a type explosion. Each time `Path` is used, it calls `PathTree` two times, which calls `Path` once for each key, which calls `PathTree` two times etc... Also, there is no need for the `-?`. By removing `-?` and using `[keyof T]` instead of `keyof PathTree`, we get identical results in a much more efficient way by avoiding the types explosion. – TheMrZZ Jun 10 '22 at 09:59
  • 2
    @TheMrZZ You are absolutely right about the `[keyof T]`, that second call is pretty useless. The `-?` is there to prevent `TS` defaulting to `...any[]`. That seems to happen when `undefined` is involved as it is in optional properties. You can see that behavior when removing `-?` in the playground link. – Aram Becker Jun 10 '22 at 19:19
  • Hi, I've been using your above solution but I was wondering if there's a way that you can get the PathValue given a Path. For example: `function assignDeep>(state: T, path: TPath, value: unknown): T` I'm trying to get value type as being the PathValue of TPath. – Wawy Sep 01 '22 at 12:48
7

Here's my approach for it, I took it from this article TypeScript Utility: keyof nested object and twisted it to support self-referencing types:

Using TS > 4.1 (dunno if it would work with prev versions)

type Key = string | number | symbol;

type Join<L extends Key | undefined, R extends Key | undefined> = L extends
  | string
  | number
  ? R extends string | number
    ? `${L}.${R}`
    : L
  : R extends string | number
  ? R
  : undefined;

type Union<
  L extends unknown | undefined,
  R extends unknown | undefined
> = L extends undefined
  ? R extends undefined
    ? undefined
    : R
  : R extends undefined
  ? L
  : L | R;

// Use this type to define object types you want to skip (no path-scanning)
type ObjectsToIgnore = { new(...parms: any[]): any } | Date | Array<any>

type ValidObject<T> =  T extends object 
  ? T extends ObjectsToIgnore 
    ? false & 1 
    : T 
  : false & 1;

export type DotPath<
  T extends object,
  Prev extends Key | undefined = undefined,
  Path extends Key | undefined = undefined,
  PrevTypes extends object = T
> = string &
  {
    [K in keyof T]: 
    // T[K] is a type alredy checked?
    T[K] extends PrevTypes | T
      //  Return all previous paths.
      ? Union<Union<Prev, Path>, Join<Path, K>>
      : // T[K] is an object?.
      Required<T>[K] extends ValidObject<Required<T>[K]>
      ? // Continue extracting
        DotPath<Required<T>[K], Union<Prev, Path>, Join<Path, K>, PrevTypes | T>       
      :  // Return all previous paths, including current key.
      Union<Union<Prev, Path>, Join<Path, K>>;
  }[keyof T];

EDIT: The way to use this type is the following:

type MyGenericType<T extends POJO> = {
  keys: DotPath<T>[];
};

const test: MyGenericType<NestedObjectType> = {
  // If you need it expressed as ["nest", "c"] you can
  // use .split('.'), or perhaps changing the "Join" type.
  keys: ['a', 'nest.c', 'otherNest.c']
}

IMPORTANT: As DotPath type is defined now, it won't let you chose properties of a any field that's an array, nor will let you chose deeper properties after finding a self-referencing type. Example:

type Tree = {
 nodeVal: string;
 parent: Tree;
 other: AnotherObjectType 
}

type AnotherObjectType = {
   numbers: number[];
   // array of objects
   nestArray: { a: string }[];
   // referencing to itself
   parentObj: AnotherObjectType;
   // object with self-reference
   tree: Tree
 }
type ValidPaths = DotPath<AnotherObjectType>;
const validPaths: ValidPaths[] = ["numbers", "nestArray", "parentObj", "tree", "tree.nodeVal", "tree.parent", "tree.obj"];
const invalidPaths: ValidPaths[] = ["numbers.lenght", "nestArray.a", "parentObj.numbers", "tree.parent.nodeVal", "tree.obj.numbers"]

Finally, I'll leave a Playground (updated version, with case provided by czlowiek488 and Jerry H)

EDIT2: Some fixes to the previous version.

EDIT3: Support optional fields.

EDIT4: Allow to skip specific non-primitive types (like Date and Arrays)

  • Can you please edit and improve the answer with an example of how to use this approach? – lepsch Jun 15 '22 at 21:16
  • @lepsch alright, thanks for asking. I'll leave a playground too to test it. – Joaquín Michelet Jun 16 '22 at 02:11
  • It almost work, however there is one case it does not work. If you have an object like this interface AnotherObjectType {a: { b: { c: string; zzz: AnotherObjectType } }} It will show only 'a','a.b', any chance some would like to fix it? – czlowiek488 Jul 16 '22 at 17:53
  • Hey @czlowiek488 nice catch, I think I got it covered after having had the same issue but I forgot to update this post, I also found that the 'K extends DotPath' condition to detect self referencing was wrong, since it checked against prop names only, so it will cut the path if a property was named exactly like a previous one in the tree. Check out if the edit works for you! – Joaquín Michelet Jul 24 '22 at 19:48
  • Works great thanks - it really helps, however I firstly I though it's broken but... it just take a lot of time for vscode to get all the suggestions. – czlowiek488 Jul 24 '22 at 20:40
  • I have not tested it on 'large structures' but it would make sense that, depending on the number of possible paths, it would take its time. Either way I'm glad if it helps. – Joaquín Michelet Jul 24 '22 at 20:53
  • I really like your solution and i think it's the best one so far. However, there is one use-case where it doesn't work. If the object were to be optional, then it gives a type error! try adding these 3 lines to the bottom of the playground to see what i mean! type AnotherObjectType3 = {a?: { b: { c: string; zzz: AnotherObjectType2 } } }; type ValidPaths3 = DotPath; const validPath3: ValidPaths3[] = ["a", "a.b", "a.b.c", "a.b.zzz", "a.b.c"]; – Jerry H. Aug 15 '22 at 08:27
  • Hey @JerryH. good catch! I'll review it – Joaquín Michelet Nov 09 '22 at 12:10
  • I think I fixed the problem @JerryH. In case you still need it. Fortunately, it was a quick fix. The playground was updated with your example – Joaquín Michelet Nov 09 '22 at 12:27
  • `const y: DotPath<{ x: Date[] }> = "x"` is an error – Kevin Beal Nov 17 '22 at 19:54
  • 1
    @KevinBeal Fixed, the problem was because it asked if the value extended from "object", which is true for the Date type (among others). I added a way to configure other complex types which you do not want to take into account – Joaquín Michelet Nov 23 '22 at 18:54
5

I came across this solution that works with nested object properties inside arrays and nullable members (see this Gist for more details).

type Paths<T> = T extends Array<infer U>
  ? `${Paths<U>}`
  : T extends object
  ? {
      [K in keyof T & (string | number)]: K extends string
        ? `${K}` | `${K}.${Paths<T[K]>}`
        : never;
    }[keyof T & (string | number)]
  : never;

Here's how it works:

  • It takes an object or array type T as a parameter.
  • If T is an array, it uses the infer keyword to infer the type of its elements and recursively applies the Paths type to them.
  • If T is an object, it creates a new object type with the same keys as T, but with each value replaced by its path using string literals.
  • It uses the keyof operator to get a union type of all the keys in T that are strings or numbers.
  • It recursively applies the Paths type to the remaining values.
  • It returns a union type of all the resulting paths.

The Paths type can be used this way:

interface Package {
  name: string;
  man?: string[];
  bin: { 'my-program': string };
  funding?: { type: string; url: string }[];
  peerDependenciesMeta?: {
    'soy-milk'?: { optional: boolean };
  };
}

// Create a list of keys in the `Package` interface
const list: Paths<Package>[] = [
  'name', // OK
  'man', // OK
  'bin.my-program', // OK
  'funding', // OK
  'funding.type', // OK
  'peerDependenciesMeta.soy-milk', // OK
  'peerDependenciesMeta.soy-milk.optional', // OK
  'invalid', // ERROR: Type '"invalid"' is not assignable to type ...
  'bin.other', // ERROR: Type '"other"' is not assignable to type ...
];
4

this might help you, bro

https://github.com/react-hook-form/react-hook-form/blob/master/src/types/path/eager.ts#L61

Path<{foo: {bar: string}}> = 'foo' | 'foo.bar'

https://github.com/react-hook-form/react-hook-form/blob/master/src/types/path/eager.ts#L141

PathValue<{foo: {bar: string}}, 'foo.bar'> = string
wangzi
  • 41
  • 1
3

So the solutions above do work, however, they either have a somewhat messy syntax or put lots of strain on the compiler. Here is a programmatic suggestion for the use cases where you simply need a string:

type PathSelector<T, C = T> = (C extends {} ? {
    [P in keyof C]: PathSelector<T, C[P]>
} : C) & {
    getPath(): string
}

function pathSelector<T, C = T>(path?: string): PathSelector<T, C> {
    return new Proxy({
        getPath() {
            return path
        },
    } as any, {
        get(target, name: string) {
            if (name === 'getPath') {
                return target[name]
            }
            return pathSelector(path === undefined ? name : `${path}.${name}` as any)
        }
    })
}

type SomeObject = {
    value: string
    otherValue: number
    child: SomeObject
    otherChild: SomeObject
}
const path = pathSelector<SomeObject>().child.child.otherChild.child.child.otherValue
console.log(path.getPath())// will print: "child.child.otherChild.child.child.otherValue"
function doSomething<T, K>(path: PathSelector<T, K>, value: K){
}
// since otherValue is a number:
doSomething(path, 1) // works
doSomething(path, '1') // Error: Argument of type 'string' is not assignable to parameter of type 'number'

The type parameter T will always remain the same type as the original requested object so that it may be used to verify that the path actually is from the specified object.

C represents the type of the field that the path currently points to

nosknut
  • 51
  • 1
  • 4
3

Aram Becker's answer with support for arrays and empty paths added:

type Vals<T> = T[keyof T];
type PathsOf<T> =
    T extends object ?
    T extends Array<infer Item> ?
    [] | [number] | [number, ...PathsOf<Item>] :
    Vals<{[P in keyof T]-?: [] | [P] | [P, ...PathsOf<T[P]>]}> :
    [];
Luke Miles
  • 941
  • 9
  • 19
2
import { List } from "ts-toolbelt";
import { Paths } from "ts-toolbelt/out/Object/Paths";

type Join<T extends List.List, D extends string> = T extends []
  ? ""
  : T extends [(string | number | boolean)?]
  ? `${T[0]}`
  : T extends [(string | number | boolean)?, ...infer U]
  ? `${T[0]}` | `${T[0]}${D}${Join<U, D>}`
  : never;

export type DottedPaths<V> = Join<Paths<V>, ".">;
2

Here is my solution. Supports dtos, literal types, not required keys, arrays and the same nested. Use the type named GetDTOKeys

type DTO = Record<string, any>;
type LiteralType = string | number | boolean | bigint;
type GetDirtyDTOKeys<O extends DTO> = {
  [K in keyof O]-?: NonNullable<O[K]> extends Array<infer A>
    ? NonNullable<A> extends LiteralType
      ? K
      : K extends string
        ? GetDirtyDTOKeys<NonNullable<A>> extends infer NK
          ? NK extends string
            ? `${K}.${NK}`
            : never
          : never
        : never
    : NonNullable<O[K]> extends LiteralType
      ? K
      : K extends string
        ? GetDirtyDTOKeys<NonNullable<O[K]>> extends infer NK
          ? NK extends string
            ? `${K}.${NK}`
            : never
          : never
        : never
}[keyof O];
type AllDTOKeys = string | number | symbol;
type TrashDTOKeys = `${string}.undefined` | number | symbol;
type ExcludeTrashDTOKeys<O extends AllDTOKeys> = O extends TrashDTOKeys ? never : O;
type GetDTOKeys<O extends DTO> = ExcludeTrashDTOKeys<GetDirtyDTOKeys<O>>;

You can see the code and examples on playground

James
  • 325
  • 1
  • 3
  • 15
Yuriy Lug
  • 41
  • 8
  • Other answers do not allow for accessing nested attributes of objects contained in arrays whereas this one does. – James Mar 14 '23 at 18:32
0

This is my solution :)

type Primitive = string | number | boolean;

type JoinNestedKey<P, K> = P extends string | number ? `${P}.${K extends string | number ? K : ''}` : K;

export type NestedKey<T extends Obj, P = false> = {
  [K in keyof T]: T[K] extends Primitive ? JoinNestedKey<P, K> : JoinNestedKey<P, K> | NestedKey<T[K], JoinNestedKey<P, K>>;
}[keyof T];
itay oded
  • 978
  • 13
  • 22
0

I came across this question while searching for a way to strongly type paths of my objects. I found that Michael Ziluck's answer is the most elegant and complete one, but it was missing something I needed: handling array properties. What I was needing was something that, given this sample structure:

type TypeA = {
    fieldA1: string
    fieldA2:
}

type TypeB = {
    fieldB1: string
    fieldB2: string
}

type MyType = {
    field1: string
    field2: TypeA,
    field3: TypeB[]
}

Would allow me to declare a type accepting the following values:

"field1" | "field2" | "field2.fieldA1" | "field2.fieldA2" | "field3" | "field3.fieldB1" | "field3.fieldB2"

regardless of the fact that field3 is an array.

I was able to get that by changing the Paths type as follows:

export type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]?:
        T[K] extends Array<infer U> ? `${K}` | Join<K, Paths<U, Prev[D]>> :
        K extends string | number ?
        `${K}` | Join<K, Paths<T[K], Prev[D]>>
        : never
    }[keyof T] : ""
Apperside
  • 3,542
  • 2
  • 38
  • 65
0

Here is my solution. The most shortest way I have found. Also here I have an array check

type ObjectPath<T extends object, D extends string = ''> = {
    [K in keyof T]: `${D}${Exclude<K, symbol>}${'' | (T[K] extends object ? ObjectPath<T[K], '.'> : '')}`
}[keyof T]

Playground link

Dromo
  • 11
  • 2
0

I tried the accepted answer on this post, and it worked, but the compiler was painfully slowed down. I think the gold standard I've found for this is react-hook-form's Path type utility. I saw @wangzi mentioned it in an answer above, but he just linked to their source file. I needed this in a project I'm working on, and we're (unfortunately) using Formik, so they didn't want me to install RHF just for this util. So I went through and extracted all of the dependent type utils so I could use them independently.

type Primitive = null | undefined | string | number | boolean | symbol | bigint;

type IsEqual<T1, T2> = T1 extends T2
  ? (<G>() => G extends T1 ? 1 : 2) extends <G>() => G extends T2 ? 1 : 2
    ? true
    : false
  : false;

interface File extends Blob {
  readonly lastModified: number;
  readonly name: string;
}

interface FileList {
  readonly length: number;
  item(index: number): File | null;
  [index: number]: File;
}

type BrowserNativeObject = Date | FileList | File;

type IsTuple<T extends ReadonlyArray<any>> = number extends T['length']
  ? false
  : true;

type TupleKeys<T extends ReadonlyArray<any>> = Exclude<keyof T, keyof any[]>;

type AnyIsEqual<T1, T2> = T1 extends T2
  ? IsEqual<T1, T2> extends true
    ? true
    : never
  : never;

type PathImpl<K extends string | number, V, TraversedTypes> = V extends
  | Primitive
  | BrowserNativeObject
  ? `${K}`
  : true extends AnyIsEqual<TraversedTypes, V>
  ? `${K}`
  : `${K}` | `${K}.${PathInternal<V, TraversedTypes | V>}`;

type ArrayKey = number;

type PathInternal<T, TraversedTypes = T> = T extends ReadonlyArray<infer V>
  ? IsTuple<T> extends true
    ? {
        [K in TupleKeys<T>]-?: PathImpl<K & string, T[K], TraversedTypes>;
      }[TupleKeys<T>]
    : PathImpl<ArrayKey, V, TraversedTypes>
  : {
      [K in keyof T]-?: PathImpl<K & string, T[K], TraversedTypes>;
    }[keyof T];

export type Path<T> = T extends any ? PathInternal<T> : never;

After testing, I found that it stops as soon as it hits a self referencing type loop, which I think is a reasonable approach. It also has support for stopping at any BrowserNativeObject, which in this case should really be treated as a primitive/stopping point. I can't claim that I fully understand how this type works, but I do know it performs very well, and it's the best option I've found to use in my own projects.

Here's a playground demoing it

Chris Sandvik
  • 1,787
  • 9
  • 19