CalibanAngel
CalibanAngel

Reputation: 1691

Typescript: deep keyof of a nested object

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

Answers (16)

thomasrea0113
thomasrea0113

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

Chris Sandvik
Chris Sandvik

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

lukenofurther
lukenofurther

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

jcalz
jcalz

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"

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

Upvotes: 282

Albert Ma&#241;osa
Albert Ma&#241;osa

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:

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

Upvotes: 9

Yuriy Lug
Yuriy Lug

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

wangzi
wangzi

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

Dromo
Dromo

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]

Playground link

Upvotes: 0

Joaqu&#237;n Michelet
Joaqu&#237;n Michelet

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

Aram Becker
Aram Becker

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"

Link to TS playground

Upvotes: 16

Apperside
Apperside

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

nosknut
nosknut

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

itay oded
itay oded

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

Omer Prizner
Omer Prizner

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

Luke Miles
Luke Miles

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

mindlid
mindlid

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>

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;

Upvotes: 45

Related Questions