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 Parameters
and 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 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.