DEV Community

loading...

Typescript function return type based on parameters

llldar profile image Nathaniel ・2 min read

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' };
};
Enter fullscreen mode Exit fullscreen mode

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}`
  );
})();
Enter fullscreen mode Exit fullscreen mode

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' };
};
Enter fullscreen mode Exit fullscreen mode

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}`
  );
})();
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

Now the code is working as expected.

Discussion (0)

pic
Editor guide