Reputation: 1691
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
?
Upvotes: 151
Views: 88649
Reputation: 449
I know this has already been answered, but I wanted to throw my very over-engineered solution into the ring. This solution has the added support of array types, including inferring the type of a specific element in the case of read-only arrays with a fixed length. In such a case, the index is also verified to be inside the bounds of the array. I've also included a sample get
method that demonstrates how the property path is intended to be parsed.
/**
* some type magic that determines if the number supplied is positive or zero
*/
type IsPositive<N extends number> = `${N}` extends `-${string}` ? false : true;
/**
* asserts that T is not never and returns R. useful if the validity of R is dependent upon T
*/
type NotNever<T, R> = T extends never ? never : R;
/**
* generic return type that is asserted as successful via the first tuple item. This enabled the destinction between
* values that are explicitly set as undefined, and those that are undefined because some property in the path hierarchy
* was undefined
*/
type TryReturn<R> = readonly [true, R] | readonly [false, undefined];
/**
* creates an array type with length N where all elements are zero. If the array would exceed the length
* specified by Limit, returns never
*/
type ArrayOfZero<N extends number, Limit = -1, Acc extends number[] = []> = Acc['length'] extends Limit
? never
: Acc['length'] extends N
? Acc
: ArrayOfZero<N, Limit, [...Acc, 0]>;
/**
* returns true if the number specified by A is less than or equal to the length of array L
*/
type LessThanOrEqualTo<A extends number, L extends readonly unknown[]> = ArrayOfZero<A, L['length']> extends never
? false
: true;
// /**
// * if T is nullable, then the property specified by Prop should also be nullable
// */
// type CoalescedPropertyType<T, Prop, RT = Exclude<T, undefined>> = undefined extends T
// ? Prop extends keyof RT ? RT[Prop] | undefined : never
// : Prop extends keyof T ? T[Prop] : never;
/**
* get's the type of the element in the array at the given Index. Index is only relavent for fixed-length, readonly
* arrays, or if Index is negative
*/
type ArrayElement<ArrayType, Index extends number = 0> = IsPositive<Index> extends true
? ArrayType extends unknown[] // check non-readonly array first since readonly array is less restrictive of a type
? ArrayType[number] // array is not fixed length, just get the type of the array
: ArrayType extends readonly unknown[]
// array is fixed length, return the type at the specified index if the index is within the bounds of the array
? LessThanOrEqualTo<Index, ArrayType> extends true ? ArrayType[Index] : never
: never
: never;
/**
* given either a property name, OR a property name and index in the form propertyName[0], determines the type. We operate on
* RT which excludes undefined as this will handle nullable properties
*/
type TypeOfProperty<T, Prop, RT = Exclude<T, undefined>> = Prop extends `${infer PropName extends keyof RT & string}[${infer Index extends number}]`
? ArrayElement<RT[PropName], Index>
: Prop extends keyof RT ? RT[Prop] : never;
/**
* given a property path string, determiens the type, or never if the property is not valid. Nullable properties
* will not be explicitly coalesced
*/
type PropertyType<T, Path extends string> = Path extends `${infer Prop}.${infer Rest}`
? PropertyType<TypeOfProperty<T, Prop>, Rest>
: TypeOfProperty<T, Path>;
const arrayRe = /(?<prop>.+)\[(?<index>\d+)]/;
/**
* asserts that the path provided is a valid property path for the type T
* @param obj
* @param path
*/
function get<T, Path extends string, PT = PropertyType<T, Path>>(obj: T, path: NotNever<PT, Path>): TryReturn<PT>;
// hacky way to ensure the generic parameter PT is expliclity provided, so errors are shown as expected and the above overload is
// picked by default https://stackoverflow.com/questions/51173191/typescript-require-generic-parameter-to-be-provided
/**
* when PT is expliclity specified, allows for passing unverified (ie. raw string) paths, strictly specifying the return type
* @param obj
* @param path
*/
function get<PT = void>(obj: unknown & (PT extends void ? 'Return Type not Specified' : unknown), path: string): TryReturn<PT>;
/**
* evaluates the path string specified and returns the value for the given object
* @param obj
* @param path
* @returns
*/
function get<PT>(obj: unknown, path: string): TryReturn<PT> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let curr: any = obj;
const members = path.split('.');
for (let i = 0; i < members.length; i++) {
if (curr == null)
return [false, undefined];
const arrayMatch = members[i].match(arrayRe);
if (arrayMatch) {
const propName = arrayMatch.groups!.prop;
if (!(propName in curr))
return [false, undefined];
const prop = curr[arrayMatch.groups!.prop];
if (!Array.isArray(prop))
return [false, undefined];
const index = Number(arrayMatch.groups!.index);
if (index >= prop.length)
return [false, undefined];
curr = prop[index];
continue;
}
const propName = members[i];
if (!(propName in curr))
return [false, undefined];
curr = curr[members[i]];
}
return [true, curr];
}
type Person = {
array?: {
test: string;
other?: { [key: string]: { value: boolean } }[];
}[];
name?: readonly [first: string, last: string];
hobbies?: readonly [lifting: boolean];
age: number;
address?: {
street: string;
city: string;
state: string;
};
interests: {
dogs: boolean;
cats: boolean;
extra?: { [key: string]: { value: boolean } }[];
};
family?: (Partial<Person> & Omit<Person, 'interests' | 'array'> & { relationship: Relationship })[];
};
// couple of working examples
const [firstNameExists, firstName] = get(person, 'name[0]');
if (firstNameExists) {
// firstName will not be undefined
}
get(person, 'family[0].interests.extra[0].mermaids.value')
// possible path not known at compile time.
const path = 'unknown.from.input';
get<unknown>(person, path);
get(person, 'name[2]'); // error, name is a fixed-length array of length 2
get(person, 'name[-1]'); // error, -1 is out of bounds
get(person, 'family.'); // error, invalid path format
get(person, 'array2[0].other[0]'); // error, invalid property in path
Upvotes: 0
Reputation: 1927
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, 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
Upvotes: 10
Reputation: 553
type-fest
now has a Paths
type, which does pretty much exactly what the OP asked for.
type NestedObjectType = {
a: string;
b: string;
nest: {
c: string;
};
otherNest: {
c: string;
};
};
type MyGenericType<T extends object> = {
keys: Paths<NestedObjectType>;
// => 'a' | 'b' | 'nest' | 'nest.c' | 'otherNest' | 'otherNest.c'
};
By default it goes 10-levels deep, presumably for performance, although there's an option to adjust that. There's also an option for what notation to use for arrays and numeric object keys, i.e. with brackets or dots.
Work is currently being done to add an option to narrow which paths to take, based on the value type and hopefully whether or not it's a leaf.
Upvotes: 0
Reputation: 327754
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"
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.
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 Cons
ing 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']]
}
Upvotes: 282
Reputation: 95
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:
T
as a parameter.T
is an array, it uses the infer
keyword to infer the type of its elements and recursively applies the Paths
type to them.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.keyof
operator to get a union type of all the keys in T
that are strings or numbers.Paths
type to the remaining values.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 ...
];
Upvotes: 9
Reputation: 41
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
Upvotes: 2
Reputation: 61
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
Upvotes: 6
Reputation: 11
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]
Upvotes: 0
Reputation: 71
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)
Upvotes: 7
Reputation: 2176
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"
Upvotes: 16
Reputation: 3602
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] : ""
Upvotes: 0
Reputation: 51
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
Upvotes: 3
Reputation: 1040
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];
Upvotes: 0
Reputation: 134
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>, ".">;
Upvotes: 2
Reputation: 1160
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]>]}> :
[];
Upvotes: 3
Reputation: 2646
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>
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;
Upvotes: 45