Search code examples
typescripttypesparametersreturn-type

Get generic ReturnType of a generic function called with certain Parameter types


Functionally, I want to provide 2 interface to access my database:

  • dao may be used by admins or regular users, so we need to provide isAdmin:boolean as first param of each function (eg: updateUser(isAdmin: boolean, returnUser))
  • daoAsAdmin on the other hand provides an interface where methods can be called without the isAdmin param (eg: updateUser(returnUser))

Here is a code example:

type User = { name: string }

type DaoAsAdmin = {
    updateUser<ReturnUser extends boolean>(
        returnUser: ReturnUser
    ): ReturnUser extends true ? User : string
}

type Dao = {
    // injects `isAdmin` as first param of all dao methods
    [K in keyof DaoAsAdmin]: (isAdmin: boolean, ...params: Parameters<DaoAsAdmin[K]>) => ReturnType<DaoAsAdmin[K]>
}

// Here is the real code
const dao: Dao = {
    updateUser(isAdmin, returnUser) {
      throw 'not implemented'
    }
  }

// Here I use this proxy trick to inject isAdmin = true 
// as the first param of each dao methods
const daoAsAdmin = new Proxy(dao, {
    get(target, prop, receiver) {
        return function (...params) {
            const NEW_PARAMS = [true, ...params]
            return target[prop](NEW_PARAMS)
        }
    },
}) as DaoAsAdmin

// So now, I can call updateUser like so
const userAsAdmin = daoAsAdmin.updateUser(true) // is type User as expected
const userStringAsAdmin = daoAsAdmin.updateUser(false) // is type string as expected
// unfortunately this doesn't work with dao
const user = dao.updateUser(false, true) // is type string | User when User was expected
const userAsStr = dao.updateUser(false, false) // is type string | User when string was expected

So I tried different things but couldn't get dao functions to return the right type. It seems it needs a mix of Parametersand ReturnType but there is nothing about using ReturnType and providing type of parameters the function will use.

What should I change in Dao type definition to meet my expectations ?

The real life example is much more complex, and unfortunately I must declare types and constants separately. Let me know if you need further details.

Typescript playground


Solution

  • TypeScript unfortunately doesn't allow the arbitrary manipulation of generic function types at the type level. TypeScript lacks higher kinded types as requested in microsoft/TypeScript#1213, and even if it had them it's not obvious how you'd write the type transformation you're looking for.

    If you try to use conditional types like the Parameters<T> or ReturnType<T> utility types on generic functions, it will end up erasing the generics. So there's no good way to write the type manipulation that you're doing so that it preserves generics.


    There is some support for manipulating generic function types at the value level, as implemented in microsoft/TypeScript#30125. So given a value gf of a generic function type, you can write another function hof() such that hof(gf) returns a related generic function type. For your example code it would look like:

    function injectIsAdmin<A extends any[], R>(
        f: (...a: A) => R
    ): (isAdmin: boolean, ...a: A) => R {
        throw 0; // you can implement this if you want but it's not needed
    }
    

    And you can see how it works on an example:

    const g = <T extends string, U extends number>(t: T, u: U) => [t, u] as const;
    // const g: <T extends string, U extends number>(
    //   t: T, u: U
    // ) => readonly [T, U];
    
    const gi = injectIsAdmin(g);
    // const gi: <T extends string, U extends number>(
    //   isAdmin: boolean, t: T, u: U
    // ) => readonly [T, U];
    

    That's great, but it doesn't scale the way you want. You can't do it for mapped types:

    function mapInjectAsAdmin<A extends Record<keyof R, any[]>, R extends Record<keyof A, any>>(
        f: { [K in keyof A]: (...args: A[K]) => R[K] } & { [K in keyof R]: (...args: A[K]) => R[K] }
    ): { [K in keyof A]: (isAdmin: boolean, ...args: A[K]) => R[K] } {
        throw 0;
    }
    const badGi = mapInjectAsAdmin({ oops: g });
    // const badGi: {  
    //   oops: (isAdmin: boolean, t: string, u: number) => readonly [string, number]; 👎
    // } 
    

    So you'd have to define Dao from DaoAsAdmin by manually walking through all the keys. And you need to use a function to do this, so if you wanted to just compute the types, you'd have to fool the compiler into thinking you were actually running code you're not running. Something like this mess:

    function daoTypeBuilder() {
    
        if (true as false) throw 0; // exit fn without the compiler realizing
        
        function injectIsAdmin<A extends any[], R>(
            f: (...a: A) => R
        ): (isAdmin: boolean, ...a: A) => R {
            throw 0; 
        }
       
        const daoAsAdmin: DaoAsAdmin = null!
    
        const dao = {
            // manually write this out for each of these
            updateUser: injectIsAdmin(daoAsAdmin.updateUser)
        } satisfies Record<keyof DaoAsAdmin, any>
    
     return dao;
    }
    
    type Dao = ReturnType<typeof daoTypeBuilder>
    /* type Dao = {
        updateUser: <ReturnUser extends boolean>(
            isAdmin: boolean, returnUser: ReturnUser
        ) => ReturnUser extends true ? User : string;
    } */
    

    That's exactly the type you want, but I don't know if it's worth it. You can essentially trick the compiler into computing the type you care about, but it requires a lot of sketchy manual code.


    So there you go; it's not possible to do this without some unpleasant tricks.

    Playground link to code