The power of TypeScript Generics and Conditional Types
Tags: webdev, typescript, javascript, react
Published: 2022-07-14 10:48:09

While developing the react hooks for [engagespot](https://github.com/Engagespot/engagespot), I came across a complex use case.

`useEngagespot` is a hook/function which takes multiple arguments and returns an object. The properties returned by `useEngagespot()` depend on `plugins`, passed through the argument. If you've ever used `react-table`, you might be familiar with this pattern.

For example,
If I pass `useInfiniteScroll` in the plugins array, I should get back `hasMore` and `setLoaderRef` properties along with the other common properties. Or if I pass `useFloatingNotification`, I should get `getButtonProps` and `getPanelProps` as return properties. Or, if I pass both `useInfiniteScroll` and `useFloatingNotification`, I should get all the properties.

There are lots of permutations, combinations, and other fine details, but this is the essence of it.

 
```ts
const {notifications, setLoaderRef, hasMore} = useEngagespot({userId, apiKey, plugins: [useInfiniteScroll]})


const {notifications, getButtonProps, getPanelProps} = useEngagespot({userId, apiKey, plugins: [useFloatingNotification]})

const {notifications, setLoaderRef, hasMore, getButtonProps, getPanelProps} = useEngagespot({userId, apiKey, plugins: [useInfiniteScroll, useFloatingNotification]})
```

I would love to go more deeply into why this pattern is a beast, but we'll save that for another post. For now, know it was working fine and solving the problems it was meant to solve... in JavaScript at least.
The real challenge was to set the correct return type based on the function arguments in TypeScript. So that we can get the "IntelliSense" working in VSCode and not need to worry about looking at the docs for return types. I tried different approaches, but none of them were perfect. I decided to give up and ended up settling for passing a static return type that had all the properties always regardless of the plugins.

But I was still not satisfied with how it turned out. I decided to try something else. It turns out I was not even close to utilizing the true power of TypeScript. I got exactly what I wanted by using **Generics** and **Conditional Types**
 

### Generics

> Generics allow "components" to work over a variety of types rather than a single one. This allows users to consume these components and use their own types.

Let's look more into what it means.

Imagine you have an identity function that can take some values and return the same value.

```js
function identity(values) {
    return value
}

const idNum = identity(5)
const idStr = identity("Hello World")
const idObj = identity({foo: "bar"})
```

As you can see, I can pass any value that returns the same value with the same type.
If I want to create the same function in TypeScript, how would I do that? One way is to set `values` as `any` so that it can accept any value. But doing that would lose our original type definition. The proper way to do this is via generics.

```ts
function identity<T>(values: T): T {
    return values;
}

const idNum = identity(5) // returns number
const idStr = identity("Hello World") // returns string
const idObj = identity({foo: "bar"}) // returns object
```
The `identity` function takes a "generic parameter T," which we assign as the type of values and the function's return type.

When the function gets called with some value, the TypeScript compiler dynamically asserts the value type and passes it to `T`.

Generics are a powerful feature that can do much more. You can read more about them [here](https://www.typescriptlang.org/docs/handbook/2/generics.html)


### Conditional Types

> Conditional types help describe the relation between the types of inputs and outputs.

Let's look at an example from the TypeScript docs,

```ts
interface IdLabel {
  id: number /* some fields */;
}
interface NameLabel {
  name: string /* other fields */;
}
 
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
```
`createLabel()` takes either a `string` or `number` as an argument and should return an object with an `id` property if the argument is of type number. Else it should return another object with the `name` property.

As we see above, we used function overloading to do this. Although this works, it's not scalable. As new types come, we would end up writing more and more overloaded function definitions.

What if there was a way to dynamically determine the type to return based on the argument type. Well, that's precisely what conditional types help us to do.

```ts
type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}
```

Let's go through it to see what it is doing.

We defined a type called `NameOrId`, which takes a generic parameter T with a type constraint that says `T` can either be a `string` or a `number`. On the RHS, we use the conditional type syntax that says, "if T is `string`, use the interface `IdLabel` as the type for `NameOrId`. Else use `NameLabel`.

By using conditional types, we've eliminated the usage of multiple overloaded functions and kept our code DRY. Yay!

The TypeScript docs cover conditional types in much more detail. Check it out [here](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html).

### Piecing everything together

Now that we know about Generics and Conditional Types, let's combine them and see how we can solve our problem.

Since it's too long to type out the whole thing here, I embedded a code sandbox playground to check it out.
 

{% embed https://codesandbox.io/embed/green-water-iv6brg?fontsize=14&hidenavigation=1&theme=dark&view=editor %}

_Side note: The typescript errors do not show up in the embedded version. Open the sandbox in a new window to see it correctly._