I'll lead off by saying: While a fun thought experiment, I wouldn't recommend this due to the amount of recursion it requires.
It requires two recursive types, A type to get a valid set of keys inferred from an object type, and a getter to access the property given those validated keys. For TypeScript < 4.5, the depth limit will be a tuple of length 10.
Validation:
// walk through the keys and validate as we recurse. If we reach an invalid
// key, we return the currently validated set along with a type hint
type ValidatedKeys<K extends readonly PropertyKey[], O, ValidKeys extends readonly PropertyKey[] = []> =
K extends readonly [infer Key, ...infer Rest]
// Excluding undefined to allow `a?.b?.c`
? Key extends keyof Exclude<O, undefined>
? Rest extends []
? [...ValidKeys, Key] // case: nothing left in the array, and the last item correctly extended `keyof O`.
: Rest extends readonly PropertyKey[] // obligatory typeguard
? ValidatedKeys<Rest,Exclude<O, undefined>[Key], [...ValidKeys, Key]> // recurse
: never // impossible, we've sufficiently typechecked `Rest`
: [...ValidKeys, keyof Exclude<O, undefined>] // case: key doesn't exist on object at this level, adding `keyof O` will give a good type hint
: [...ValidKeys] // case: empty top level array. This gives a good typehint for a single incorrect string;
Getter:
// access a property recursively. Utilizes the fact that `T | never` === `T`
type GetNestedProp<K extends readonly PropertyKey[], O, MaybeUndef extends undefined = never> =
K extends readonly [infer Key, ...infer Rest]
? Key extends keyof O
? Rest extends []
? O[Key] | MaybeUndef // succesful exit, no more keys remaining in array. Union with undefined if needed
/* obligatory typeguard to validate the inferred `Rest` for recursion */
: Rest extends readonly PropertyKey[]
// If it's potentially undefined, We're going to recurse excluding the undefined, and then unify it with an undefined
? O[Key] extends infer Prop
? Prop extends undefined
? GetNestedProp<Rest, Exclude<Prop, undefined>, undefined>
: GetNestedProp<Rest,Prop, MaybeUndef>
: never // impossible, `infer Prop` has no constraint so will always succeed
:never // impossible, we've typechecked `Rest` sufficiently
: undefined // case: key doesn't exist on object at this level
: undefined; // case: empty top level array
In order for a function to correctly infer a generic, that generic needs to be present as a possible argument. What we want is ValidKeys, but we can't do that without having Keys itself as a potential argument. So we make use of a conditional for the ...keys argument to force it to resolve.
Regarding the return type, Even though GetNestedProp will potentially be a union with undefined, the compiler can't infer that it definitely is in the case where your else branch is hit. So you can either make the return type this awkward conditional, or //@ts-expect-error the else branch return statement with a simpler return type of GetNestedProp<Keys, Obj>. That alternative is included in the playground:
function getNestedItem<Obj, Keys extends readonly [keyof Obj, ...PropertyKey[]], ValidKeys extends ValidatedKeys<Keys, Obj>>(
obj: Obj,
...keys: ValidKeys extends Keys ? Keys : ValidKeys
): GetNestedProp<Keys, Obj> extends undefined ? GetNestedProp<Keys, Obj> | undefined : GetNestedProp<Keys,Obj> {
let level: any = obj;
for (const key of keys) {
if (level !== undefined) {
level = level[key];
} else {
return;
}
}
return level;
}
Given a type with an optional property, digging into that property will convert the nested property type into a union with undefined:
interface HasOpt {
a: { b: number };
aOpt?: {b: number };
}
declare const obj: HasOpt;
const ab = getNestedItem(obj, "a", "b") // number
const abOpt = getNestedItem(obj, "aOpt", "b") // number | undefined
playground