Generic Tips Part 1: Use Classes and Currying to create new inference sites

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:

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

// Correct usage:
const url = getUrl<API>('/users/:userId', {userId: 'bob'});
// returns '/users/bob'

// Incorrect usage; these should be errors:
getUrl<API>('/users/:userId/profile', {userId: 'bob'});
// endpoint doesn't exist
getUrl<API>('/users/:userId', {user: 'bob'});
// should be userId, not user

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:

type ExtractRouteParams<Route> = /* ... */;
type Params = ExtractRouteParams<'/users/:userId'>;
// type is {userId: string}

Given that, here's how you might declare getUrl:

declare function getUrl<
API, Path extends keyof API
>(
path: Path, params: ExtractRouteParams<Path>
): string;

Unfortunately, when you try to use this, you'll get an unexpected error:

getUrl<API>('/users/:userId', {userId: 'bob'});
// ~~~ Expected 2 type arguments, but got 1. (2558)

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:

getUrl<API, '/users/:userId'>('/users/:userId', {userId: 'bob'});  // ok

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:

declare class URLMaker<API> {
getUrl<
Path extends keyof API
>(
path: Path,
params: ExtractRouteParams<Path>
): string;
}

And here's how you use it:

const urlMaker = new URLMaker<API>();
urlMaker.getUrl('/users/:userId', {userId: 'bob'});

urlMaker.getUrl('/users/:userId/profile', {userId: 'bob'});
// ~~~~~~~~~~~~~~~~~~~~~~~~
// '"/users/:userId/profile"' is not assignable to '"/users/:userId" | "/users"'
urlMaker.getUrl('/users/:userId', {user: 'bob'});
// ~~~~~~~~~~~
// '{ user: string; }' is not assignable to '{ userId: string; }'.

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:

declare function getDate(mon: string, day: number): Date;
getDate('dec', 25);

You could instead write a function that returns a function:

declare function getDate(mon: string): (day: number) => Date;
getDate('dec')(25);

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:

declare function getUrl<API>():
<Path extends keyof API>(
path: Path,
params: ExtractRouteParams<Path>
) => string;

Now getUrl takes no parameters, but it returns a function that takes two. Here's how you use it:

getUrl<API>()('/users/:userId', {userId: 'bob'});  // ok

getUrl<API>()('/users/:userId/profile', {userId: 'bob'});
// ~~~~~~~~~~~~~~~~~~~~~~~~
// '"/users/:userId/profile"' is not assignable to '"/users/:userId" | "/users"'
getUrl<API>()('/users/:userId', {user: 'bob'});
// ~~~~~~~~~~~
// '{ user: string; }' is not assignable to '{ userId: string; }'.

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:

declare function urlMaker<API>(): {
getUrl<Path extends keyof API>(
path: Path, params: ExtractRouteParams<Path>
): string;
}

const maker = urlMaker<API>();
maker.getUrl('/users/:userId', {userId: 'bob'}); // ok

The only difference between this and the class exmaple 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.

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 »