typescriptmapped-typesvariadic-tuple-types

TypeScript generic map variadic tuple values to nested mapped type


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


Solution

  • 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