Search code examples
typescriptgenericsrecursiontype-inferencekeyof

Dynamic return type based on array of keys inputted to function


I have x number of modules that are used in my AWS lambda functions such as a UserModule, NotificationsModule, CompanyModule, etc. I created an interface that describes the structure of the modules as the following:

interface Modules {
    company: ICompanyModule
    user: {
        base: IUserModule
    }
    notifications: {
        settings: INotificationSettingsModule
        details: INotificationDetailsModule
    }
}

Let's say I need the company and user base module in a function I would have to import both manually and instantiate them. If I had just a handful of functions that would work but that is not the case. To decrease the amount of imports and manual work I thought that I would create a factory method that would return an object with only the modules that the function needs. For example:

// Single module
const module = factory('company') // Type would be ICompanyModule

// Multiple Modules
const modules = factory(['company', 'user.base']) // Type would be { company: ICompanyModule, 'user.base': IUserModule } 

The input to the factory method would be an array of keys in the Modules interface.

I'm about to get to a "solution" but I can't seem to fix a Type instantiation is excessively deep and possibly infinite error. Below is what I currently have:

// This gets all the keys and nested keys of my Modules interface
type Path<T, Key extends keyof T = keyof T> = Key extends string
    ? T[Key] extends Record<string, unknown>
        ?
            | `${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<unknown>>> & string}`
            | `${Key}.${Exclude<keyof T[Key], keyof Array<unknown>> & string}`
            | Key
        : Key
    : never

// This gets the value for the Path wether it be nested or not
type PathValue<T, P extends Path<T>> = P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
        ? Rest extends Path<T[Key]>
            ? PathValue<T[Key], Rest>
            : never
        : never
    : P extends keyof T
        ? T[P]
        : never

// This is my ReturnType that takes in the a single key or array of keys and builds the object type
type ReturnObject<T extends Object, P extends Path<T> | Array<Path<T>>> = P extends Array<Path<T>>
    ? {
        [Key in P[number]]: Key extends keyof T
            ? T[Key] extends Object
                ? Key extends Path<T[Key]>
                    ? ReturnObject<T[Key], Key>
                    : T[Key]
                : never
            : Key extends `${infer K}.${infer Rest}`
                ? K extends keyof T
                    ? T[K] extends Object
                        ? Rest extends Path<T[K]>
                            ? ReturnObject<T[K], Rest>
                            : never
                        : never
                    : never
                : never
    }
    : P extends keyof T
        ? T[P]
        : P extends `${infer K}.${infer Rest}`
            ? K extends keyof T
                ? T[K] extends Object
                    ? Rest extends Path<T[K]>
                        ? ReturnObject<T[K], Rest>
                        : never
                    : T[K]
                : never
            : never

// This is the factory function declaration
declare function factory<T extends ModuleFactory, P extends Path<T> | Array<Path<T>>>(
    modules: P
): ReturnObject<T, P>

Now this works. When I create call factory with any of the given keys I get the correct object structure back but Typescript is throwing the Type instantiation is excessively deep and possibly infinite error in the ReturnType at { [Key in P[number]]: ... }

After a few days of messing around with this my best guess is that the issue is that P can extend Array<Path<T>> and Typescript is trying to handle the case the the array passed in could have a huge length and in turn cause almost infinite recursion.


Solution

  • You may use non-fallible path getter Get to obtain the value and generic type validator conforms to allow only proper paths

    https://tsplay.dev/mqyVZm

    type conforms<T, V> = T extends V ? T : V;
    
    type validatePath<Path, Obj> =
      | Path extends keyof Obj ? Path
      : Path extends `${infer F extends keyof Obj & string}.${infer L}` ? `${F}.${validatePath<L, Obj[F]>}`
      : keyof Obj & string
    
    type Get<Obj, Path> =
      | Path extends `${infer F}.${infer L}` ? Get<Get<Obj, F>, L> 
      : Path extends keyof Obj ? Obj[Path]
      : [Obj, Path]
    
    function factoryS<Path extends string>(path: conforms<Path, validatePath<Path, Modules>>): Get<Modules, Path> {
      return null!;
    }
    function factoryA<const A>(list: conforms<A, { [K in keyof A]: validatePath<A[K], Modules> }>)
      : A extends readonly string[] ? { [K in A[number]]: Get<Modules, K> } : never {
      return null!;
    }
    // you may make them single function overloads