- Part 0: The Golden Rule of Generics
- Part 1: Use Classes and Currying to create new inference sites
- Part 2: Intersect what you have with whatever TypeScript wants
- Part 3: Avoid Repeating Type Expressions
- Part 4: The Display of Types
Perhaps the best thing about Effective-style books is that they present hard-earned lessons learned from years of experience using a language. The author has spent years falling into traps and digging out of them so that you don't have to! But this also makes Effective books difficult to write. You can't just read the documentation on a new feature and write an Item about it. You need to use that feature, make mistakes with it, and eventually learn how to use it well. This takes time.
Effective TypeScript has relatively little to say about "advanced" topics like conditional types. That's largely because I didn't have enough practical experience working with them when I wrote the book. That's changed this year because of work that eventually made its way into the open source crosswalk library.
I found writing the types for crosswalk extremely difficult this spring, but I found it much easier when I reworked it for open source this fall. So I must have learned something! This is the first in a series of posts that will explain some of the tips I've picked up along the way. I'll aim to post one each week for the rest of this month.
Of course, any discussion of generics and "fancy" types should start with Rule Zero: don't use them unless you need to! My previous post on the "Golden Rule of Generics" can help you tell whether this is the case.
The problem
In crosswalk, you define an API using a TypeScript interface
that looks something like this:
|
(Endpoint
and GetEndpoint
are simple type aliases defined by the library. Their parameters are request and response types.)
In addition to providing tools to safely implement an API for this schema using express, crosswalk also offers a function to construct URLs for API endpoints. This checks a few things: that the endpoints exist, that you specify the proper path parameters, and that they have the correct type (string
, not string | null
). This is extremely helpful for making safe API calls from the client.
Here's how we'd like that function work:
|
The logic to extract types for the path parameters is fascinating but a bit off-topic. If you're interested, check out my previous post: TypeScript Splits the Atom! For now, let's assume we have a generic type that does this:
|
Given that, here's how you might declare getUrl
:
|
Unfortunately, when you try to use this, you'll get an unexpected error:
|
The problem is that type inference in TypeScript is all or nothing: either you can let TS infer all the type parameters from usage, or you can specify all of them explicitly. There's no in between. (There was an attempt to support this in TypeScript 3.1 but it was abandoned.)
The API
parameter is free: it can't possibly be inferred. So it would seem the only solution here is to write the Path
type explicitly:
|
This works, but yuck! Surely there's a better way. We need to somehow separate the place where we write the type parameter (API
) from the place where we infer it.
Solution 1: Classes
TypeScript has one very familiar tool to introduce a new inference site: classes.
Here's how you can use a class to solve this problem:
|
And here's how you use it:
|
This produces exactly the errors we were hoping for. Hurray!
What used to be a function that needed two generic type parameters is now a class with one generic type (that you specify explicitly) and a method with one generic type (that's inferred). TypeScript is perfectly happy to let you bind the API
parameter when you call the class's constructor (new URLMaker<API>()
) and then bind Path
when you call the getUrl
method.
Using classes to create a distinct binding site is particularly effective when you have multiple methods that all require the same generic parameter. Check out crosswalk's typed-router.ts
for an example of this.
Solution 2: Currying
Fun fact: programming languages don't really need functions that take more than one parameter. Instead of:
|
You could instead write a function that returns a function:
|
Note the slightly different syntax to call the second version. This practice is known as currying, after Haskell Curry, who always disavowed having come up with the technique.
Currying gives us the flexibility we need to introduce as many inference sites as we like. Each function can infer new generic parameters.
Here's how you can rework getUrl
using functions that return functions:
|
Now getUrl
takes no parameters, but it returns a function that takes two. Here's how you use it:
|
So this works in the case where we want it to and fails in the others. Perfect!
For other examples of using currying with generics, check out my posts on building a type-safe query selector and using typed identity functions to guide inference.
This isn't as distinct from the class approach as it might initially appear. If you use a different name and return an object type instead of a function, they look nearly identical:
|
The only difference between this and the class example is the keyword new
.
Conclusion
If you want to specify some generic parameters explicitly while allowing others to be inferred, classes and currying are your two options.
So which one should you prefer? Ultimately it's up to you! Whichever one feels most comfortable and produces the API you find most convenient is the way to go.
The currying approach does have at least one advantage in the context of TypeScript, however, which we'll discuss in next week's tip. Stay tuned!
As always, here's a playground with complete examples from this post.