DEV Community

Cover image for The power of TypeScript Generics and Conditional Types
Hemand S
Hemand S

Posted on

The power of TypeScript Generics and Conditional Types

While developing the react hooks for 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.

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

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.

function identity(values) {
    return value
}

const idNum = identity(5)
const idStr = identity("Hello World")
const idObj = identity({foo: "bar"})
Enter fullscreen mode Exit fullscreen mode

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.

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

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

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,

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

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.

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

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

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.

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.

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

Top comments (0)