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]})
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"})
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
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;
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";
}
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)