Generic Tips Part 2: Intersect what you have with whatever TypeScript wants

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.

Recall that an API definition in crosswalk looks something like this:

export interface API {
'/users': {
get: GetEndpoint<UsersResponse>;
post: Endpoint<CreateUserRequest, User>;
};
'/users/:userId': {
get: GetEndpoint<User>;
put: Endpoint<UpdateUser, User>;
}
}

(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:

app.get('/users/:userId', async (request, response) => {
const {userId} = request.params;
const user = await getUserById(userId);
response.json(user);
// (error handling omitted)
})

There are a few issues with this from a TypeScript perspective:

  1. The type of request.params is any. This means that userId also gets an any type, and this destructuring assignment is completely unsafe.
  2. The call to response.json() doesn't check the type of its argument. In fact, nothing checks that we call response.json at all.

Crosswalk defines a typed wrapper so that the registration looks like this:

const typedRouter = new TypedRouter<API>(app);
typedRouter.get(
'/users/:userId',
async params => getUserById(params.userId)
);

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:

class TypedRouter<API> {
constructor(private router: express.Router) {}
get<Path extends string>(
path: Path,
handler: (
params: ExtractRouteParams<Path>,
) => Promise<unknown> // TODO: fill in this type!
) {
this.router.get(path, (request, response) => {
handler(request.params)
.then(obj => response.json(obj));
})
}
}

This gets us some of the way there: the params parameter to our handler is getting the correct type:

path parameters being inferred

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:

type Response = API['/users/:userId']['get']['response'];
// type is User

If you plug that into our get function, however, you get several errors:

get<Path extends string>(
path: Path,
handler: (
params: ExtractRouteParams<Path>,
) => Promise<API[Path]['get']['response']>
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type 'Path' cannot be used to index type 'API'. (2536)
// Type '"get"' cannot be used to index type 'API[Path]'. (2536)
// Type '"response"' cannot be used to index type 'API[Path]["get"]'. (2536)
) {
// ...
}

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:

- get<Path extends string>(
+ get<Path extends keyof API>(

Unfortunately, this introduces a new error:

this.router.get(path, (request, response) => {
// ~~~~
// Type 'string | number | symbol' is not assignable to type '(string | RegExp)[]'.

(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:

declare type PropertyKey = string | number | symbol;

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:

- get<Path extends keyof API>(
+ get<Path extends keyof API & string>(

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:

Promise<API[Path]['get']['response']>
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type '"get"' cannot be used to index type 'API[Path]'. (2536)
// Type '"response"' cannot be used to index type 'API[Path]["get"]'. (2536)

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!

Promise<API[Path]['get' & keyof API[Path]]['response']>
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type '"response"' cannot be used to index type 'API[Path]["get" & keyof API[Path]]'. (2536)

Voila! The error is gone! It's like magic. You can get rid of the remaining error using the same trick. Watch out, this is going to get a little wordy:

Promise<API[Path]['get' & keyof API[Path]]['response' & keyof API[Path]["get" & keyof API[Path]]]>

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:

typedRouter.get(
'/users/:userId',
async params => null
// ~~~~
// Type 'Promise<null>' is not assignable to type 'Promise<User>'.
// Type 'null' is not assignable to type 'User'. (2322)
);

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:

type LooseKey<T, K> = T[K & keyof T];

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 monstruous type expression from earlier:

- Promise<API[Path]['get' & keyof API[Path]]['response' & keyof API[Path]["get" & keyof API[Path]]]>
+ Promise<LooseKey<LooseKey<API[Path], 'get'>, 'response'>>

As before, this still resolves all our types perfectly. You could even define a LooseKey2 to simplify things a bit more:

type LooseKey<T, K> = T[K & keyof T];
type LooseKey2<T, K1, K2> = LooseKey<LooseKey<T, K1>, K2>;

Promise<LooseKey2<API[Path], 'get', 'response'>>

(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:

type Caps<T> = {
[K in keyof T as Capitalize<K>]: T[K];
// ~
// Type 'K' does not satisfy the constraint 'string'.
// Type 'keyof T' is not assignable to type 'string'.
// Type 'string | number | symbol' is not assignable to type 'string'.
// Type 'number' is not assignable to type 'string'. (2344)
}

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!

type Caps<T> = {
[K in keyof T as Capitalize<K & string>]: T[K];
}

and now this works as you'd expect:

type T = Caps<{name: string, age: number}>;
// type T = {
// Name: string;
// Age: number;
// }

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:

class TypedRouter<API> {
constructor(private router: express.Router) {}
get<Path extends keyof API & string>(
path: Path,
handler: (
params: ExtractRouteParams<Path>,
) => Promise<LooseKey2<API[Path], 'get', 'response'>>
) {
this.router.get(path, (request, response) => {
handler(request.params)
.then(obj => response.json(obj));
})
}
}

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.

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »