Typescript function return type based on parameters
Tags: typescript, function, parameters
Published: 2021-01-11 17:09:41

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:

```typescript
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:

```typescript
(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`:

```typescript
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:

```typescript
(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: 

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