Generic Tips Part 3: Avoid Repeating Type Expressions

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.

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:

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

In the last post we looked at how crosswalk defines safe wrappers to register handlers for an endpoint. Here's what usage looks like:

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

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:

type LooseKey<T, K> = T[K & keyof T];
type LooseKey2<T, K1, K2> = LooseKey<LooseKey<T, K1>, K2>;
type ExtractRouteParams<Path extends string> = ...;
// See https://twitter.com/danvdk/status/1301707026507198464

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'>>
) {
// ... implementation ...
}
}

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:

get<Path extends keyof API & string>(
path: Path,
handler: (
params: ExtractRouteParams<Path>,
request: express.Request<
ExtractRouteParams<Path>,
LooseKey2<API[Path], 'get', 'response'>
>,
response: express.Response<
LooseKey2<API[Path], 'get', 'response'>
>
) => Promise<LooseKey2<API[Path], 'get', 'response'>>
) { /* ... */ }

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:

function f<T>(
a: Expresion<Involving<T>>,
b: Expression<Involving<T>>
): Expression<Involving<T>> { /* ... */ }

You can factor the repeated bits out into a helper type:

type Exp<T> = Expression<Involving<T>>;
function f<T>(a: Exp<T>, b: Exp<T>): Exp<T> { /* ... */ }

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

type GetResponse<API, Path extends keyof API & string> =
LooseKey2<API[Path], 'get', 'response'>;

get<Path extends keyof API & string>(
path: Path,
handler: (
params: ExtractRouteParams<Path>,
request: express.Request<
ExtractRouteParams<Path>,
GetResponse<API, Path>
>,
response: express.Response<GetResponse<API, Path>>
) => Promise<GetResponse<API, Path>>
)

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:

function f<T>(
a: Expresion<Involving<T>>,
b: Expression<Involving<T>>
): Expression<Involving<T>> { /* ... */ }

You could write:

function f<T>() {
type Exp = Expresion<Involving<T>>;

return (a: Exp, b: Exp): Exp => {
// ...
};
}

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:

get<Path extends keyof API & string>(
path: Path
) => {
type Params = ExtractRouteParams<Path>;
type ResponseType = LooseKey2<API[Path], 'get', 'response'>;

return (
handler: (
params: Params,
request: express.Request<Params, Response>,
response: express.Response<Response>
): Promise<Response>) => {
// ...
}
);
}

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:

type T<A> = [F<A>, F<A>, F<A>];

You'd write:

type T<A, B extends F<A> = F<A>> = [B, B, B];

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:

get<
Path extends keyof API & string,
ResponseType extends
LooseKey2<API[Path], 'get', 'response'> =
LooseKey2<API[Path], 'get', 'response'>,
>(
path: Path,
handler: (
params: ExtractRouteParams<Path>,
request: express.Request<
ExtractRouteParams<Path>,
ResponseType
>,
response: express.Response<ResponseType>
) => Promise<ResponseType>
) { /* ... */ }

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:

get(
path: Path,
handler: (
params: Params,
request: express.Request<Params, ResponseType>,
response: express.Response<ResponseType>
) => Promise<ResponseType>
) where
Path extends keyof API & string,
Params = ExtractRouteParams<Path>,
ResponseType = LooseKey2<API[Path], 'get', 'response'>,
{ /* ... */ }

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.

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 »