typescript: is there a recursive keyof?

Viewed 2888

Is there a way to have code like this compile and be type safe?

type ComplexObject = {
  primitive1: boolean;
  complex: {
    primitive2: string;
    primitive3: boolean;
  }
};

interface MyReference {
  myKey: keyof ComplexObject;
}

const works1: MyReference = {
  myKey: "primitive1"
}

const works2: MyReference = {
  myKey: "complex"
}

const iWantThisToCompile1: MyReference = {
  myKey: "complex.primitive2" // Error: Type '"complex.primitive2"' is not assignable to type '"primitive1" | "complex"'.
}

const iWantThisToCompile2: MyReference = {
  myKey: "complex['primitive3']" // Error: Type '"complex['primitive3']"' is not assignable to type '"primitive1" | "complex"'.
}

// const iDontWantThisToCompile1: MyReference = {
//  myKey: "primitive2"
// }

// const iDontWantThisToCompile2: MyReference = {
//  myKey: "primitive3"
// }

You can play around with this code here.

4 Answers

This is possible with the new template literal types and recursive types in TypeScript 4.1.

Property and Index Access Type

Here's a way of defining this that works beyond a single level. It's possible to use less types than this, but this approach doesn't have additional unused type parameters in its public API.

export type RecursiveKeyOf<TObj extends object> = {
  [TKey in keyof TObj & (string | number)]:
    RecursiveKeyOfHandleValue<TObj[TKey], `${TKey}`>;
}[keyof TObj & (string | number)];

type RecursiveKeyOfInner<TObj extends object> = {
  [TKey in keyof TObj & (string | number)]:
    RecursiveKeyOfHandleValue<TObj[TKey], `['${TKey}']` | `.${TKey}`>;
}[keyof TObj & (string | number)];

type RecursiveKeyOfHandleValue<TValue, Text extends string> =
  TValue extends any[] ? Text :
  TValue extends object
    ? Text | `${Text}${RecursiveKeyOfInner<TValue>}`
    : Text;

Property Access Only Type

If you just need property access it's much simpler:

export type RecursiveKeyOf<TObj extends object> = {
  [TKey in keyof TObj & (string | number)]:
    TObj[TKey] extends any[] ? `${TKey}` :
    TObj[TKey] extends object
      ? `${TKey}` | `${TKey}.${RecursiveKeyOf<TObj[TKey]>}`
      : `${TKey}`;
}[keyof TObj & (string | number)];

Explanation and Breakdown

export type RecursiveKeyOf<TObj extends object> = (
  (
    // Create an object type from `TObj`, where all the individual
    // properties are mapped to a string type if the value is not an object
    // or union of string types containing the current and descendant
    // possibilities when it's an object type.
    {
      // Does this for every property in `TObj` that is a string or number
      [TKey in keyof TObj & (string | number)]:
        RecursiveKeyOfHandleValue<TObj[TKey], `${TKey}`>;
    }
  )[
    keyof TObj & (string | number) // for every string or number property name
  ] // Now flatten the object's property types to a final union type
);

// This type does the same as `RecursiveKeyOf`, but since
// we're handling nested properties at this point, it creates
// the strings for property access and index access
type RecursiveKeyOfInner<TObj extends object> = {
  [TKey in keyof TObj & (string | number)]:
    RecursiveKeyOfHandleValue<TObj[TKey], `['${TKey}']` | `.${TKey}`>;
}[keyof TObj & (string | number)];

type RecursiveKeyOfHandleValue<TValue, Text extends string> =
  // If the value is an array then ignore it, providing back
  // only the passed in text
  TValue extends any[] ? Text :
  // If the value is an object...
  TValue extends object
    // Then...
    // 1. Return the current property name as a string
    ? Text
      // 2. Return any nested property text concatenated to this text
      | `${Text}${RecursiveKeyOfInner<TValue>}`
    // Else, only return the current text as a string
    : Text;

For example:

// this type
{
  prop: { a: string; b: number; };
  other: string;
}

// goes to
{
  prop: "prop" | "prop.a" | "prop.b";
  other: "other";
}

// goes to
"prop" | "prop.a" | "prop.b" | "other"

I received help elsewhere and was given this type:

type ComplexObject = {
  primitive1: boolean;
  complex: {
    primitive2: string;
    primitive3: boolean;
  }
};

type RecKeyof<T, Prefix extends string = never> =  
  T extends string | number | bigint | boolean 
  | null | undefined | ((...args: any) => any ) ? never : {
  [K in keyof T & string]: [Prefix] extends [never] 
    ? K | `['${K}']` | RecKeyof<T[K], K> 
    : `${Prefix}.${K}` | `${Prefix}['${K}']` | RecKeyof<T[K],`${Prefix}.${K}` | `${Prefix}['${K}']`>
}[keyof T & string];

interface MyReference {
  myKey: RecKeyof<ComplexObject>;
}

const works1: MyReference = {
  myKey: "primitive1"
}

const works2: MyReference = {
  myKey: "complex"
}

const iWantThisToCompile1: MyReference = {
  myKey: "complex.primitive2"
}

const iWantThisToCompile2: MyReference = {
  myKey: "complex['primitive3']"
}

// const iDontWantThisToCompile1: MyReference = {
//  myKey: "primitive2"
// }

// const iDontWantThisToCompile2: MyReference = {
//  myKey: "primitive3"
// }

You can see it working here.

Here's the type with better documentation:

type RecKeyof<T, Prefix extends string = ""> = 
  // If T matches any of the types in the union below, we don't care about its properties.
  // We must exclude functions, otherwise we get infinite recursion 'cause functions have
  // properties that are functions: i.e. myFunc.call.call.call.call.call.call...
  T extends string | number | bigint | boolean | null | undefined | ((...args: any) => any ) 
    ? never // skip T if it matches
    // If T doesn't match, we care about it's properties. We use a mapped type to rewrite
    // T.
    // If T = { foo: { bar: string } }, then this mapped type produces
    // { foo: "foo" | "foo.bar" }
    : {
      // For each property on T, we remap the value with
      [K in keyof T & string]: 
        // either the current prefix.key or a child of prefix.key.
        `${Prefix}${K}` | RecKeyof<T[K],`${Prefix}${K}.`>
    // Once we've mapped T, we only care about the values of its properties
    // so we tell typescript to produce the union of the mapped types keys.
    // { foo: "1", bar: "2" }["foo" | "bar"] generates "1" | "2"
    }[keyof T & string];

No, unfortunately Typescript cannot do that.

Edit: TS 4.1 added template literals, see David Sherret's answer for how to use them in a recursive type

The only thing it supports that's close is a recursive array of paths:

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 ComplexObject = {
  primitive1: boolean;
  complex: {
    primitive2: string;
    primitive3: boolean;
  }
};

interface MyReference {
  myKey: Paths<ComplexObject>;
}

const works1: MyReference = {
  myKey: ["primitive1"]
}

const works2: MyReference = {
  myKey: ["complex"]
}

const iWantThisToCompile1: MyReference = {
  myKey: ["complex", "primitive2"]
}

const iWantThisToCompile2: MyReference = {
  myKey: ["complex", "primitive3"]
}

Libraries like lodash's get work with both your "complex.primitive2" and an array of paths like ["complex", "primitive2"]. So while this may not be the exact answer you're looking for, hopefully it gives you a more type-safe alternative.

I don't think this is an exact duplicate, but here is the answer I got the Paths type alias from: TypeScript type definition for an object property path

This should be doable with template literals:

type ComplexObject = {
  primitive1: boolean;
  complex: {
    primitive2: string;
    primitive3: boolean;
  }
};

type PathOf<T> =  {
  [K in keyof T]: T[K] extends object ? K | `${K}.${PathOf<T[K]>}` | `${K}['${PathOf<T[K]>}']` : K
}[keyof T]

type PathOfComplexObject = PathOf<ComplexObject>

Typescript Playground

The playground is showing some complaints but if you hover over PathOfComplexObject you can see the generated types. I understand why it is complaining:

Type instantiation is excessively deep and possibly infinite.

but I'm not sure about:

Type 'K' is not assignable to type 'string | number | bigint | boolean | null | undefined'.

Related