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.