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.
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.
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
where we have to define
DeepIdx<T, KS>to be the nested property type of the typeTat the path represented byKS, andValidPathMap<T, KS>to be something that examinesTandKSand makes sureKSis a valid path ofT. If it is valid, thenValidPathMap<T, KS>should evaluate toKS. If it is invalid, it should evaluate to something valid which is "close" toKSin the sense that the error message will let the user know what's wrong. IdeallyValidPathMap<T, KS>will also let users know the next valid key ifKSis just a partial path.The easy part is
DeepIdx<T, KS>:Here we don't really care if
KSis a valid path. IfKSis a tuple that starts with a valid key ofT, we index and recurse. Otherwise we just returnTas-is. IfKSends up being a valid path, thenDeepIdx<T, KS>is the property there. If not, thenDeepIdx<T, KS>is the property at the longest valid prefix inKS.Then
ValidPathMap<T, KS>is significantly more annoying and difficult. In order to get the inference you want onaccess(), you wantValidPathMap<T, KS>to be a homomorphic mapped type onKS(see What does "homomorphic mapped type" mean?). That lets the compiler inferKSfromValidPathMap<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:That maps over
keyof KSso it's homomorphic. And the underlying implementation isValidatePath<T, KS>. If you analyze that you can see thatValidatePathMap<T, KS>will end up being justKSifKSis okay, orValidatePath<T, KS>if it's not.So we still need to implement
ValidatePath<T, KS>. Here's one way to do it:That's a tail recursive conditional type where we accumulate (into
A) the valid initial part ofKS. If the whole thing is valid we end up returningA(which will beKS). If we find something invalid, we return the valid initial piece withkeyof Treplacing the first invalid part.Okay, let's test it out:
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:
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