typescripttypes

typescript: is there a recursive keyof?


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.


Solution

  • 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"