14

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.

yudhiesh
  • 6,383
  • 3
  • 16
  • 49
Daniel Kaplan
  • 62,768
  • 50
  • 234
  • 356

4 Answers4

37

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"
David Sherret
  • 101,669
  • 28
  • 188
  • 178
  • 1
    Oh, this is cool, I tried doing string concatenation in a type before, but I must have been doing it wrong. Ahh it was before 4.1 – Aaron Dec 17 '20 at 00:39
  • can you explain how this works? ideally explaining the property access support one first, since it's simpler – Daniel Kaplan Dec 17 '20 at 04:07
  • @DanielKaplan I added an explanation for the property acess only type. The property and index access type follows similar principles. – David Sherret Dec 17 '20 at 05:01
  • When one of the properties is an array (e.g. names: string[]), this solution also recurses into the methods of the array, which is undesirable. That is, the RecursiveKeyOf type includes names.concat, names.values, etc. How can this be prevented? – Marduk Apr 09 '21 at 19:15
  • @Marduk I updated the example to handle this. One way to handle it is to test for the value being an array in a conditional type and exit early instead of traversing its properties. – David Sherret Dec 02 '21 at 01:36
  • When you reference types like a `Date` then it is also possible to reference `primitive1.getDate` (Demo: https://tsplay.dev/mAjgRW) That is not a correct object key reference. – dewey Feb 09 '22 at 14:23
  • hi @DavidSherret in webstorm intellisense im getting a perfect result, but still the compiler warn me when im using a nested string like like 'user.phone.number' and on direct keyof it passes. you have an idea what can be the problem? – Roi Dayan Feb 25 '22 at 16:34
  • 1
    "TS2589: Type instantiation is excessively deep and possibly infinite." - TS 4.6.3 – Kevin Beal May 19 '22 at 18:20
  • @DavidSherret is it possible to extract the final data type for return type? That is: `function foo(bar: RecursiveKeyOf): FinalValueOfRecursiveKey`. Basically, if the `bar` parameter is `'prop.a'` from your example, i want the return type to be `string` – user3534080 Nov 04 '22 at 00:56
  • Oh mine! This is definitely what I have tried to do but I failed. Thank you so much! @DavidSherret – mrabaev48 Dec 28 '22 at 08:52
  • Just wanted to come in here and and say that if someone wants to include array elements and its subkeys as `'arrayProp[0]' | 'arrayProp[0].a' | 'arrayProp[1]' | `'arrayProp[1].a' | ...` then just replace ```extends any[] ? `${TKey}` :``` with ```extends any[] ? `${TKey}` | `${TKey}[${number}]` | `${TKey}[${number}].${RecursiveKeyOf}` :``` – 5ar Jan 26 '23 at 15:50
  • This is fantastic work. The only Typescript syntax I don't understand (and can't find the reference for) is when you flatten the object type into its properties using `{ ... }[keyof TObj & (string | number)]`. What is the Typescript feature you're using there? – sedge Mar 13 '23 at 19:08
5

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];
Daniel Kaplan
  • 62,768
  • 50
  • 234
  • 356
0

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

Aaron
  • 144
  • 6
  • That's cool, I think that works for my needs. Is there a way of saying the path is restricted to properties that have a boolean type? – Daniel Kaplan Dec 16 '20 at 23:59
  • Checkout [Conditional Types](https://www.typescriptlang.org/docs/handbook/advanced-types.html#conditional-types). The `Paths` type uses them heavily, and I'm sure there is a way to see if a value's type extends a `boolean`, or use `never` if not. I just tried modifying it myself, but wasn't able to figure it out... – Aaron Dec 17 '20 at 00:36
0

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'.

chautelly
  • 447
  • 3
  • 14
  • `PathOf` would still need some more love if you want to keep the bracket notation since adding another layer to `.complex` could result in a type `complex['primitive2.foo']` and others. – chautelly Dec 17 '20 at 00:51
  • Apparently intersecting `K` with `string` resolves the `not assignable to` issue. – chautelly Dec 17 '20 at 00:59