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
Update (2023): Check out Using infer to unpack nested types for an improved solution to the problem presented here.
Recall that an API definition in crosswalk looks something like this:
|
(Endpoint
and GetEndpoint
are simple type aliases defined by the library. Their parameters are request and response types.)
In last week's post we defined a getUrl
function to safely generate API URLs. In this week's post, we'll create type-safe wrappers around express methods to handle these endpoints.
Here's how you typically register a get
handler in express:
|
There are a few issues with this from a TypeScript perspective:
- The type of
request.params
isany
. This means thatuserId
also gets anany
type, and this destructuring assignment is completely unsafe. (Update: this is no longer the case if you're using a version of@types/express
later than 4.17.2) - The call to
response.json()
doesn't check the type of its argument. In fact, nothing checks that we callresponse.json
at all.
Crosswalk defines a typed wrapper so that the registration looks like this:
|
Instead of using response.json
to send a response, we return Promise<User>
, which TypeScript checks against the API
interface. Additionally, thanks to the magic of ExtractRouteParams
, params
has an inferred type of {userId: string}
, so the reference to params.userId
is type safe.
Let's take a crack at implementing TypedRouter
. As last week's tip explained, you can either use a class or a curried function to capture the API
type parameter. In this case let's use a class. Then we can use a method (get
) that infers a string literal type for the path. Here's a sketch:
|
This gets us some of the way there: the params
parameter to our handler is getting the correct type:
But what about that return type? Really you'd like to look it up from the API
interface given Path
.
Here's how you'd do that with a type alias:
|
If you plug that into our get
function, however, you get several errors:
|
It's reasonable for TypeScript to complain here. We haven't constrained API
or Path
, so it has no reason to believe that Path
is defined on API
, let alone that it has a get
property which has a response
property.
You can fix the first of these errors by changing:
|
Unfortunately, this introduces a new error:
|
(The full error is quite long, but that's the important part.)
The problem is that express wants the path
parameter to be a string
(or a RegExp
), but in TypeScript, keyof API
is a PropertyKey
, which is defined in the standard library as:
|
The number
is a lie (Item 16 of Effective TypeScript, "Prefer Arrays, Tuples, and ArrayLike to number Index Signatures", explains this in great detail). But path
could certainly be a string
or symbol
. We don't care about the symbol
or number
cases, though. We only want the string
. To get down to this, you can use an intersection type:
|
This eliminates the error with router.get
! This intersection trick winds up being an extremely useful way to get rid of TypeScript errors with generics. As it turns out, it's this week's tip!
To get rid of generic type errors, intersect what you have with whatever TypeScript wants.
Let's use the same trick to get rid of the remaining two errors:
|
In the first error, TypeScript is saying that it's seeing 'get'
, but it wants something that can index API[Path]
. In other words, keyof API[Path]
. So let's intersect!
|
And the errors are gone! What's more, we have perfect type inference. The error if you return the wrong type from a handler is exactly what you'd hope for:
|
But this a mess. The issue is that for every index operator, you also have to introduce a keyof
. And with three index operators (Path
, 'get'
, 'response'
), this has spiraled out of control.
You can improve things with a short helper:
|
This is like T[K]
except that it doesn't require evidence that K
is actually a key of T
. (If it's not, it will resolve to never
.) The win here is that while T
appears twice in the definition of LooseKey
, it only appears once when you use it. Here's how you can use it to simplify the monstrous type expression from earlier:
|
As before, this still resolves all our types perfectly. You could even define a LooseKey2
to simplify things a bit more:
|
(You could define a variadic version of this that works on tuples of keys. Give it a try!)
This technique also comes in handy with template literal types. For example, let's define a generic type that capitalizes the property names of another type. You can do this using the built-in Capitalize
generic and a mapped type:
|
This looks familiar! TypeScript wants a string
, but we've got a PropertyKey
. By now you should see how to solve the problem: intersect with whatever TS wants!
|
and now this works as you'd expect:
|
Of course, there's no free lunch. The downside of this approach is that TypeScript won't check that the property accesses in your generic are safe. You'll need to verify them in some other way, probably with a test. But it's undeniably convenient to have this safety valve available! You can think of it as a rough equivalent of as any
for generic types.
Here's our final version of TypedAPI
:
|
As usual, you can find the full code for this post on the TypeScript playground.
Next week we'll add the express Request and Response objects to the callback and look at ways to mitigate the repetition of types that follows.