144

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 ?

5
  • 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 Commented Oct 17, 2019 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 ? Commented Oct 17, 2019 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<NestedObjectType> 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<Tree> for type Tree = {l: Tree, r: Tree} to be?
    – jcalz
    Commented Oct 17, 2019 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 ? Commented Oct 17, 2019 at 15:30
  • Maybe related: stackoverflow.com/questions/58361316/…
    – Paleo
    Commented Oct 17, 2019 at 15:46

14 Answers 14

263

Currently the simplest way to do this without worrying about edge cases looks like

type Paths<T> = T extends object ? { [K in keyof T]:
  `${Exclude<K, symbol>}${"" | `.${Paths<T[K]>}`}`
}[keyof T] : never

type Leaves<T> = T extends object ? { [K in keyof T]:
  `${Exclude<K, symbol>}${Leaves<T[K]> extends never ? "" : `.${Leaves<T[K]>}`}`
}[keyof T] : never

which produces

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

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"

Playground link to code


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

27
  • 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
    Commented May 6, 2020 at 15:37
  • 1
    @jcalz thank you for your answer, I actually tried and here is my question : stackoverflow.com/questions/61644053/…
    – jeben
    Commented May 6, 2020 at 19:47
  • 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<K, Paths<T[K], Prev[D]>> with (Paths<T[K], Prev[D]> extends infer R ? Join<K, R> : never).
    – Qtax
    Commented Sep 23, 2020 at 9:27
  • 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". Commented Jan 29, 2021 at 19:23
  • 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<H, T> by [H, ...T] Commented Dec 7, 2022 at 14:17
41

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;
9
  • 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
    Commented Aug 19, 2021 at 9:04
  • @DoneDeal0 yes, in that case you don't have to do anything but passing the original type.
    – mindlid
    Commented Aug 19, 2021 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? codesandbox.io/s/aged-darkness-s6kmx?file=/src/App.tsx
    – DoneDeal0
    Commented Aug 20, 2021 at 7: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<T>]: 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: codesandbox.io/s/modest-robinson-8b1cj?file=/src/App.tsx .
    – DoneDeal0
    Commented Aug 27, 2021 at 10:14
  • 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
    Commented Apr 19, 2022 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

6
  • This still seems too slow for a production environment. :( Commented Mar 22, 2022 at 17:02
  • 1
    @christo8989 Why would you use Typescript in a production env? Commented Jun 2, 2022 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. Commented Jun 6, 2022 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<T>, we get identical results in a much more efficient way by avoiding the types explosion.
    – TheMrZZ
    Commented Jun 10, 2022 at 9: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. Commented Jun 10, 2022 at 19:19
9

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 already in a separate answer, 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 my team 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

2
  • This doesn't work with arrays :/ Commented Jan 21 at 11:45
  • 1
    @CristianE. It should, depending on what you mean. If you have a type that's an array of a type, it will give you paths for parentKey.i.childKey Commented Jan 22 at 20:29
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)

11
  • Can you please edit and improve the answer with an example of how to use this approach?
    – lepsch
    Commented Jun 15, 2022 at 21:16
  • @lepsch alright, thanks for asking. I'll leave a playground too to test it. Commented Jun 16, 2022 at 2: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? Commented Jul 16, 2022 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<PrevType>' 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! Commented Jul 24, 2022 at 19:48
  • 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 Commented Nov 23, 2022 at 18:54
7

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 ...
];
6

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
1
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]>]}> :
    [];
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

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
  • Doesn't work with self-referencing type
    – m0onspell
    Commented Dec 10, 2021 at 22:13
  • @m0onspell Added a cleaner version. hope this helps. Commented Dec 22, 2021 at 22:23
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

1
  • Other answers do not allow for accessing nested attributes of objects contained in arrays whereas this one does.
    – James
    Commented Mar 14, 2023 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];
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] : ""
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

1
  • No intellisense. Commented Jan 21 at 14:07

Not the answer you're looking for? Browse other questions tagged or ask your own question.