This is part of an ongoing series on tips I learned for working with TypeScript generics from building the crosswalk library. Check out part 1 for more background.
- 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
Recently there's been some chatter online about how you should use long names for generic types (i.e. longer than just T
). I'd generalize all this a bit to say:
Just because you're writing generics, don't forget everything you've learned about programming!
So yes, give long-lived variables meaningful names. But also avoid repeating yourself by factoring out common expressions.
This post presents a few patterns for reducing repetition in generics. None of them are perfect, but they're worth learning because they're usually better than repeating yourself!
As a motivating example, we'll look at how crosswalk registers express endpoints (see the first post for background on crosswalk). Recall that an API definition in crosswalk looks something like this:
|
In the last post we looked at how crosswalk defines safe wrappers to register handlers for an endpoint. Here's what usage looks like:
|
The params
object has a type based on the path (/users/:userId
) and the response is required to be Promise<User>
(based on the API
interface). Here's the implementation we wound up with in the last post:
|
The real crosswalk API passes the express request
and response
objects to the callback as well as the path params. And express requests and responses take several generic parameters: the path params, the request body type and the response type. For a GET request, we can ignore the request type (there's no request body) but the other two are relevant. They let you reference request.params
and get a type, for example.
Here's what this looks like, focusing just on get
:
|
Wow that's a lot of a lot of repetition! The ExtractRouteParams
clause appears twice and the response type (the LooseKey2
bit) appears three times. It would be even worse for the post
handler, where we have a request body to type, too.
So what can you do about this? There are a few options. They're better than nothing but, as we'll see, none are perfect.
Factor out helper types
This is the generic equivalent of factoring out a helper function. Instead of writing:
|
You can factor the repeated bits out into a helper type:
|
(Of course, you should give the helper type a semantically meaningful name if possible.)
In the case of the crosswalk get
method, we might want to factor out a helper to extract the response
:
|
While this does cut down on repetition in the get
declaration itself, it forces us to repeat the constraint on Path
. As with helper functions, helper types work best when they are semantically meaningful on their own. This makes them easier to think about and increases the likelihood that they'll be useful in other places.
Introduce a local type alias
The first post in this series looked at how you can use classes and currying to introduce new inference sites. Currying has another advantage: it introduces a new scope in which you can create type aliases (you can't introduce a type alias scoped to a class
).
Instead of:
|
You could write:
|
By changing the signature, we're able to introduce a type alias that depends on the generic parameter, T
. This greatly simplifies the resulting generic and doesn't require us to repeat any bounds on the generic parameters.
Here's what get
might look like if we curried it:
|
This is much clearer and much less repetitive. The downside is that it's more complicated for the caller. One hybrid option is to have a public, non-curried function that delegates to a curried, internal function. See crosswalk's registerWithBody
for an example of this in action.
Add Generic Parameters with Defaults
What if you're defining a type alias, rather than a generic function? Then you can't create a local scope since there's no function body.
But if you're repeating the same type expression a lot, there is one mediocre option available: you can define another generic parameter with a default value.
Instead of writing:
|
You'd write:
|
You have to write F<A>
twice, so this trick isn't helpful unless it appears three or more times in your type alias.
You can use the same technique with generic functions. For example, here's how you might write crosswalk's get
with an extra generic parameter:
|
Because ResponseType
appears three times, defining it this way does reduce repetition. But because ExtractRouteParams<Path>
only appears twice, factoring that out in the same way wouldn't be a win.
The upside of this technique is that it effectively introduces a local type alias without changing the function signature. The downside is that it's gross and potentially confusing for your users, who may think they need to pass a value for the additional type parameter. But it works with type aliases and it's (arguably) better than nothing!
Conclusion
Just because you're doing generic programming, don't forget that you're programming! Cryptic code is hard to follow, so use meaningful type names. And repetitive code is hard to follow and error-prone, so use the techniques at your disposal to reduce repetition.
Admittedly none of these options are perfect. I've filed a feature request to support Rust-style where
syntax for generics, which would let you write get
like this:
|
In other words, you'd get the local type alias without any of the downside. This would make the story around reducing repetition much better (and this blog post happily obsolete!). If you like the idea, please go vote it up!
The posts in this series have looked at how generic types are computed and inferred. In the next (and hopefully last) post, we'll look at your options for controlling how they're displayed.