I'm trying to make a helper function that takes a nested object like JSON, and allows one to make a deep copy of a nested value at any arbitrary depth. I understand variadic tuple types and can get them to work for just passing around the tuples - but I don't know how to 'map' them to nested Picks of arbitrary depth (it may not even be possible). Here's the best I've come up with - but is still limited to needing to create as many overloads for GetNestedValue as I would care to support. I understand the various errors, I just can't come up with any way to satisfy the compiler and get type completion on the return value.
// K is arbitrary length how to express N accessors deep? in TS without a loop?
type GetNestedValue<K extends string[], O extends any> = O[K[0]][K[1]][K[2]];
function getNestedItem<Keys extends string[], Obj>(
obj: Obj, ...keys: readonly [...Keys]
): GetNestedValue<Keys, Obj> extends undefined ? undefined : GetNestedValue<Keys, Obj> {
let level: any = obj;
for (const key of keys) {
if (level !== undefined) {
level = level[key];
} else {
return;
}
}
// this will return deepClone(level);
return level;
}
const obj = {one: 1, two: {three: {four: 4}}};
// I'd prefer 'known' shapes of obj here block form entering invalid keys.
const a = getNestedItem(obj, 'one', 'two');
// here - when arbitrarily trying to grab stuff from unknown inputs - I don't want
// a warning, rather the user would just need to check `if (b !== undefined)`
const b = getNestedItem(obj as any, 'one', 'two');
link to playground
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