4

With the addition of template literal types it's now possible to express property paths (dot notation) in a type-safe way. Some users have already implemented something using template literal types or mentioned it.

I want to go a step further and also express the possibility of nulls/undefined/optionals in types, e.g. foo.bar?.foobar and foo.boo.far?.farboo should be acceptable for the compiler while foo.bar.foobar is not for the following type:

type Test = {
  foo: {
    bar?: {
      foobar: never;
      barfoo: string;
    };
    foo: symbol;
    boo: {
      far:
        | {
            farboo: number;
          }
        | undefined;
    };
  };
};

I've come so far that the optional parameter gets picked up (I don't know why, but it's working in my IDE with the same typescript version, see the screenshot below) but not the "far"-property which is explicitly marked as undefined. This playground shows my progress. Somehow the "undefined-check" doesn't work as expected.

IDE expands it correctly

rsmidt
  • 69
  • 1
  • 7
  • 2
    Does [this](https://tsplay.dev/w18ylW) work for your use cases? If so, I'll write up an answer; if not, please elaborate. I'm very skeptical of any properties of type `never`, by the way. I'm not sure what the use case for `foo.bar?.foobar` is supposed to be, but I'd like to hear a compelling use case before I worry about trying to make `DeepKeyOf` preserve keys to properties that are of type `never`. – jcalz Mar 16 '21 at 13:59
  • @jcalz It is defenitely works) – captain-yossarian from Ukraine Mar 16 '21 at 14:02
  • @jcalz, thank you very much! This seems to indeed work very well. I just wanted to cover the `never` case. I have no other reason for it. – rsmidt Mar 16 '21 at 14:07
  • 1
    Okay I'll circle back and write up an answer when I get a chance – jcalz Mar 16 '21 at 14:11

1 Answers1

21

Be warned: even with language support for recursive conditional types, it is quite easy for deep indexing operations to run afoul of the compiler's recursion limiters. Even relatively minor changes can mean the difference between a version that seems to work and one that bogs down the compiler or issues the dreaded error: "⚠ Type instantiation is excessively deep and possibly infinite. ⚠". The version of DeepKeyOf presented here seems to work, but it's definitely walking on a tightrope above an abyss of circularity.

Additional warning: something like this invariably has all sorts of edge cases. You might not be happy with how this (or any) version of DeepKeyOf<XYZ> handles things in cases where the type XYZ: has an index signature; is a union of types; is recursive like type Recursive = { prop: Recursive };; et cetera. It's possible that for each edge case there is a tweak that will behave "better" in your opinion, but handling all of them is probably outside the scope of this question.

Okay, warnings over. Let's look at DeepKeyOf<T>:


type DeepKeyOf<T> = (
  [T] extends [never] ? "" :
  T extends object ? (
    { [K in Exclude<keyof T, symbol>]:
      `${K}${undefined extends T[K] ? "?" : ""}${DotPrefix<DeepKeyOf<T[K]>>}` }[
    Exclude<keyof T, symbol>]
  ) : ""
) extends infer D ? Extract<D, string> : never;

type DotPrefix<T extends string> = T extends "" ? "" : `.${T}`;

Just to be sure, let's test it on Test:

type DeepKeyOfTest = DeepKeyOf<Test>
// type DeepKeyOfTest = "foo.foo" | "foo.bar?" | "foo.bar?.foobar" | "foo.bar?.barfoo" 
//  | "foo.boo.far?" | "foo.boo.far?.farboo"

Looks good.


Let's walk through it and see how it works:

type DeepKeyOf<T> = (
  [T] extends [never] ? "" :

Here we will make DeepKeyOf<never> explicitly return the empty string "". Something like is necessary if you want to mostly distribute DeepKeyOf<T> over unions in T while still having properties whose type is only never show up. As I said in the comments, I'm a bit skeptical of this being desired behavior. Distributing over unions is nice because it automatically makes DeepKeyOf<{a: string} | undefined> equivalent to DeepKeyOf<{a: string}> | DeepKeyOf<undefined>. But then DeepKeyOf<never> really should be never, to be consistent (since any type X is equivalent to X | never). Anyway, this is coming down to edge cases again so I'll move on:

  T extends object ? (

If T is not a primitive type then we will produce keys of some kind. Note that arrays and functions are not primitives, if it matters.

    { [K in Exclude<keyof T, symbol>]:

We will first make a mapped type with the same keys as T except for any possible symbol-valued keys. Removing symbol is important to allow every key K to be used in template literal types.

      `${K}${undefined extends T[K] ? "?" : ""}${DotPrefix<DeepKeyOf<T[K]>>}` }

This is the workhorse of the type. For each key K we start a new string with K. Then, if the property type at key K, namely T[K] can accept an undefined value, we append "?". Finally, we append DotPrefix<DeepKeyOf<T[K]>>, where DeepKeyOf<T[K]> is expected to be the union of all keys of the property T[K], and DotPrefix takes care of optionally including the "." character, explained below.

          [Exclude<keyof T, symbol>]

The mapped type we created now looks something like {a: "a.foo" | "a.bar"; b: "b"}, but we want something like "a.foo" | "a.bar" | "b" instead. We do this by indexing into the mapped type with the same keys we used to create it.

  ) : ""

If T is neither never nor a primitive, we will produce the empty string "". So DeepKeyOf<string> will be "".

) extends infer D ? Extract<D, string> : never;

This line really shouldn't be necessary, but it prevents recursion depth warnings. Essentially by writing extends infer D we are copying the result into a new parameter D and causing the compiler to defer evaluation that it would otherwise perform eagerly. The Extract<D, string> lets the compiler understand that DeepKeyOf<T> will always produce a subtype of string so that the recursive step will succeed.

Finally,

type DotPrefix<T extends string> = T extends "" ? "" : `.${T}`;

will take something like "foo" | "bar" | "" and produce ".foo" | ".bar" | "". It prepends a dot to its input unless that input is the empty string. Without such an exception you'd have types like "foo.bar.baz." that end in a dot.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • it works perfectly, but TS returns an error if I use string concatenation or a variable parameter instead of a hard-coded path. If I add a string option `type DeepKeyOfTest = DeepKeyOf | string` , the error is gone, but so is the autocompletion. Would you know how to fix it? – DoneDeal0 Oct 11 '21 at 07:49
  • 1
    Normally you'd want the compiler to *reject* `string` since it can't be sure that it conforms to `DeepKeyOf`. If you want to accept any `string` but maintain the autocompletion hints, then you're looking for [this](https://stackoverflow.com/questions/61047551/typescript-union-of-string-and-string-literals). – jcalz Oct 11 '21 at 13:06
  • Thanks it works! – DoneDeal0 Oct 11 '21 at 19:54
  • How to get value from a path? I have a case that extracting value from path 'foo.bar' from { foo: { bar: number } | number }, expect result is `number` not `never` – Zheeeng Nov 24 '21 at 05:46
  • @Zheeeng comments on an old question are not the best place to get answers to questions. Still, if you look at [this question](https://stackoverflow.com/questions/64575901/generic-function-to-get-a-nested-object-value), you can use the code in that answer to do a deep index. Like [this](https://tsplay.dev/WyOq2N). If that helped you, please consider upvoting [the answer](https://stackoverflow.com/a/64578478/2887218). – jcalz Nov 24 '21 at 15:20
  • @jcalz Thanks for the elaborate answer! I posted a similar question [here](https://stackoverflow.com/questions/70158575/extract-all-possible-a-field-paths-from-a-pojo-document-type) in case you'd like another challenge – Thijs Koerselman Nov 29 '21 at 17:16