Proper IntelliSense and type checking for deep object keys / paths as function arguments without triggering recursive type limiters

37 Views Asked by At

Suppose I have a dictionary like the following:

const obj = {
  something: 123,
  otherThing: "asd",
  nested: {
    nestedSomething: 456,
    nestedOther: "fgh",
    deepNested: {
      deepNested1: "hello",
      deepNested2: 42,
      deepNestedArr: ["a", "b", "c"],
    },
  },
};

and I want to have a function access that can be used to access the values of this dictionary like this:

access(obj, "something") //should return number
access(obj, "nested", "nestedOther") //should return string
access(obj, "nested", "deepNested", "deepNestedArr") //should return string[]
//and even:
access(obj, "nested", "deepNested", "deepNestedArr", 0) //should return string

For this, I first need a utility type that can get an object type and output a union of all possible paths to leaves in this object. I implement it like this:

type AllPaths<Obj extends object, Key = keyof Obj> = Key extends keyof Obj
  ? Readonly<Obj[Key]> extends Readonly<Array<any>>
    ? [Key] | [Key, number]
    : Obj[Key] extends object
      ? [Key] | [Key, ...AllPaths<Obj[Key]>]
      : [Key]
  : never;

And when given a concrete type as an argument, it works:

type Test = AllPaths<typeof obj>; 
//["something"] | ["otherThing"] | ["nested"] | ["nested", "nestedSomething"] | ["nested", "nestedOther"] | ["nested", "deepNested"] | ["nested", "deepNested", "deepNested1"] | [...] | [...] | [...]

Then I need an utility type that takes an object type and the path we generated earlier, indexes into the object and returns the resulting type. I implement it like this:

type GetTypeFromPath<Obj extends object, Path extends PropertyKey[]> = Path extends [
  infer Head,
  ...infer Tail extends PropertyKey[]
]
  ? Head extends keyof Obj
    ? Obj[Head] extends object
      ? GetTypeFromPath<Obj[Head], Tail>
      : Obj[Head]
    : never
  : Obj;

...which also works when given concrete arguments.

type Test2 = GetTypeFromPath<typeof obj, ["nested", "deepNested", "deepNested2"]> //number

These utilities work in isolation when given concrete types, and they are performant.

Now if I try to use them in a function with generic type arguments though, tsserver hangs a bit, then gives "Type instantiation is excessively deep.." or "Excessive stack depth comparing types..." errors depending on how I set up the generics. No matter what I do, I am not able to avoid triggering the limiters. Is there a sane way to achieve this?

declare function access<
  Obj extends object,
  //providing the default only does not work without extends for some reason
  Paths extends AllPaths<Obj> = AllPaths<Obj>,
  Ret = GetTypeFromPath<Obj, Paths>
>(
  obj: Obj,
  ...path: Paths
): Ret;

const res = access(obj, "nested", "deepNested");

Above, the generic type Ret can't be computed because it is excessively deep.

Playground link

This all is for an API boundary, so having errors at the access call site if wrong path to the object keys is entered and having proper IntelliSense while entering the keys for the function is enough for my purposes.

1

There are 1 best solutions below

2
jcalz On BEST ANSWER

These sorts of deeply recursive and nested types are always going to have bizarre edge cases, and it's inevitable that some uses of them will trigger circularity or depth limit warnings. So while I will present something that works how I think you want it for this example, I can't guarantee it will behave that way for all use cases.


Let's write the function like this

declare function access<T, const KS extends PropertyKey[]>(
  obj: T, ...ks: ValidPathMap<T, KS>): DeepIdx<T, KS>

where we have to define DeepIdx<T, KS> to be the nested property type of the type T at the path represented by KS, and ValidPathMap<T, KS> to be something that examines T and KS and makes sure KS is a valid path of T. If it is valid, then ValidPathMap<T, KS> should evaluate to KS. If it is invalid, it should evaluate to something valid which is "close" to KS in the sense that the error message will let the user know what's wrong. Ideally ValidPathMap<T, KS> will also let users know the next valid key if KS is just a partial path.


The easy part is DeepIdx<T, KS>:

type DeepIdx<T, KS extends PropertyKey[]> =
  KS extends [infer K0 extends keyof T, ...infer KR extends PropertyKey[]] ?
  DeepIdx<T[K0], KR> : T;

Here we don't really care if KS is a valid path. If KS is a tuple that starts with a valid key of T, we index and recurse. Otherwise we just return T as-is. If KS ends up being a valid path, then DeepIdx<T, KS> is the property there. If not, then DeepIdx<T, KS> is the property at the longest valid prefix in KS.


Then ValidPathMap<T, KS> is significantly more annoying and difficult. In order to get the inference you want on access(), you want ValidPathMap<T, KS> to be a homomorphic mapped type on KS (see What does "homomorphic mapped type" mean?). That lets the compiler infer KS from ValidPathMap<T, KS>.

But the underlying implementation will probably be a recursive conditional type like DeepIdx, so we need a wrapper for that underlying implmentation which is homomorphic:

type ValidPathMap<T, KS extends PropertyKey[]> = {
  [I in keyof KS]: KS extends ValidatePath<T, KS> ?
  KS[I] : I extends keyof ValidatePath<T, KS> ? ValidatePath<T, KS>[I] : never
}

That maps over keyof KS so it's homomorphic. And the underlying implementation is ValidatePath<T, KS>. If you analyze that you can see that ValidatePathMap<T, KS> will end up being just KS if KS is okay, or ValidatePath<T, KS> if it's not.

So we still need to implement ValidatePath<T, KS>. Here's one way to do it:

type ValidatePath<T, KS extends PropertyKey[], A extends PropertyKey[] = []> =
  KS extends [infer K0 extends PropertyKey, ...infer KR extends PropertyKey[]] ?
  K0 extends keyof T ?
  ValidatePath<T[K0], KR, [...A, K0]> : [...A, keyof T] : A

That's a tail recursive conditional type where we accumulate (into A) the valid initial part of KS. If the whole thing is valid we end up returning A (which will be KS). If we find something invalid, we return the valid initial piece with keyof T replacing the first invalid part.


Okay, let's test it out:

const res = access(obj, "nested", "deepNested");
//    ^? const res: { deepNested1: string; deepNested2: number; deepNestedArr: string[]; }
const res2 = access(obj, "nested", "nestedOther");
//    ^? const res2: string
const res3 = access(obj, "nested", "deepNested", "deepNestedArr");
//    ^? const res3: string[]
const res4 = access(obj, "nested", "deepNested", "deepNestedArr", 0);
//    ^? const res4: string

That stuff all works as desired, with the right types and no instantiation depth errors. When it comes to errors and IntelliSense, it also behaves as expected:

access(obj, "foo"); // error!
//          ~~~~~
// Argument of type '"foo"' is not assignable to parameter 
// of type '"nested" | "something" | "otherThing"'.

access(obj, "nested", "oops");
//                    ~~~~~~
// Argument of type '"oops"' is not assignable to parameter 
// of type '"deepNested" | "nestedSomething" | "nestedOther"'.(2345)

You can see that there are errors on the first bad key, and the error says what it expects to see.

Playground link to code