Typescript is all fun and games until you want some behaviour based on runtime values, recently I encountered a tricky problem: How do I type a function's return type based on the parameter value?
I know this sound like an anti-pattern but there are many real world use case for it, for example your function have an option
field that will determine the type of value it returns:
type User = { id: string; firstName: string; lastName: string; profilePicture?: string | ProfilePicture; }; type ProfilePicture = { height: string; width: string; url: string; }; const db = { findUserById: async (userId: string): Promise<User> => ({ id: '1', firstName: 'Bruce', lastName: 'Wayne', }), }; const generateProfilePictureById = async ( userId: string ): Promise<ProfilePicture> => ({ height: '20px', width: '20px', url: `http://example.com/${userId}.png`, }); const getUserProfile = async ( userId: string, options?: { generateProfilePicture: boolean } ) => { const user = await db.findUserById(userId); if (options?.generateProfilePicture) { return { ...user, profilePicture: await generateProfilePictureById(userId), }; } return { ...user, profilePicture: 'picture not generated' }; };
Now if you want to use getUserProfile
like:
(async () => { const user = await getUserProfile('1', { generateProfilePicture: true }); console.log( `${user.firstName} ${user.lastName} has profilePicture with height: ${user.profilePicture.height}` ); })();
Typescript will complain that height
does not exist on user.profilePicture
But you know that if generateProfilePicture
option is set to true
, user.profilePicture
will not be the inferred type string | ProfilePicture
How do we solve this problem then? Typescript have the answer: Function overload
Basically, typescript will map multiple signatures of a function in an order of their appearance in code. It will use the first matching type signature for that function.
Knowing this, let's improve the typing of our function getUserProfile
:
interface GetUserProfileType { <T extends boolean>( userId: string, options?: { generateProfilePicture: T } ): Promise< Omit<User, 'profilePicture'> & { profilePicture: T extends true ? ProfilePicture : string; } >; ( userId: string, options?: { generateProfilePicture: boolean } ): Promise<User>; } const getUserProfile: GetUserProfileType = async ( userId: string, options?: { generateProfilePicture: boolean } ) => { const user = await db.findUserById(userId); if (options?.generateProfilePicture) { return { ...user, profilePicture: await generateProfilePictureById(userId), }; } return { ...user, profilePicture: 'picture not generated' }; };
Now our user.profilePicture
will be string
when generateProfilePicture
is false
, and ProfilePicture
when generateProfilePicture
is true
.
But wait, there's more
What if we omit the options
entirely and use it like:
(async () => { const user = await getUserProfile('1'); console.log( `${user.firstName} ${user.lastName} has profilePicture with height: ${user.profilePicture.length}` ); })();
Now for the above code typescript complains: Property 'length' does not exist on type 'ProfilePicture'
. Apparently it did not match with any of the two function overloads. Well, guess three time is a charm, let's add the third function overload:
interface GetUserProfileType { <T extends { generateProfilePicture: boolean } | undefined>( userId: string, options?: T ): Promise< Omit<User, 'profilePicture'> & { profilePicture: T extends undefined ? string : never; } >; <T extends boolean>( userId: string, options?: { generateProfilePicture: T } ): Promise< Omit<User, 'profilePicture'> & { profilePicture: T extends true ? ProfilePicture : string; } >; ( userId: string, options?: { generateProfilePicture: boolean } ): Promise<User>; }
Now the code is working as expected.
Top comments (1)
Put the code together, it errors:
typescriptlang.org/play?#code/C4Tw...