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