The Golden Rule of Generics

Golden Ruler

The New TypeScript Handbook has some real gems in it. Here's what it has to say about generics:

Writing generic functions is fun, and it can be easy to get carried away with type parameters. Having too many type parameters or using constraints where they aren't needed can make inference less successful, frustrating callers of your function.

It goes on to offer a few specific pieces of advice about how to use generics, including one that I've started to think of as the "Golden Rule of Generics":

Type Parameters Should Appear Twice

Type parameters are for relating the types of multiple values. If a type parameter is only used once in the function signature, it's not relating anything.

Rule: If a type parameter only appears in one location, strongly reconsider if you actually need it.

I love this rule because it's so concrete. It gives you a specific way to tell whether any use of generics is good or bad.

But it's not always obvious how to apply this rule, and it doesn't offer much guidance about how rework your code if you're using generics poorly. So in a nod to the folks at 538, let's play the "good use of generics or bad use of generics" game. The examples that follow have been edited to protect the innocent, but they are all based on real code I've encountered either in blog posts or in print.

Let's start with the identity function:

function identity<T>(arg: T): T {
return arg;
}

Good use of generics or bad use? In this example the generic argument, T, appears in two places after its declaration:

function identity<T>(arg: T): T {
// (dec) 1 2
return arg;
}

So this passes the test and is a good use of generics. And rightly so: it relates two types because it says that the input parameter's type and the return type are the same.

How about this one?

function third<A, B, C>(a: A, b: B, c: C): C {
return c;
}

The generic parameter C appears twice, so it's fine. But A and B only appear once (other than in their declarations), so this function fails the test. In fact, we can rewrite it using only one generic parameter:

function third<C>(a: unknown, b: unknown, c: C): C {
return c;
}

Here's a function that parses YAML:

function parseYAML<T>(input: string): T {
// ...
}

Is this a good use of generics or a bad use of generics? The type paramter T only appears once, so it must be bad. How to fix it? It depends what your goal is. These so-called "return-only generics" are dangerous because they're equivalent to any, but don't use the word any:

interface Weight {
pounds: number;
ounces: number;
}

const w: Weight = parseYAML('');

The Weight here could be any type and this code would type check. If that's what you want, you may as well just be explicit about your any:

function parseYAML(input: string): any {
// ...
}

But the recommended way to do this is by returning unknown instead:

function parseYAML(input: string): unknown {
// ...
}

This will force users of the function to perform a type assertion on the result:

const w = parseYAML('') as Weight;

This is actually a good thing since it forces you to be explicit about your unsafe type assertion. There are no illusions of type safety here!

How about this one?

function printProperty<T, K extends keyof T>(obj: T, key: K) {
console.log(obj[key]);
}

Since K only appears once, this is a bad use of generics (T appears both as a parameter type and as a constraint on K). Fix it by moving the keyof T into the parameter type and eliminating K:

function printProperty<T>(obj: T, key: keyof T) {
console.log(obj[key]);
}

This function looks superficially similar:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}

This one, however, is actually a good use of generics! The trick is to look at the return type of this function. Hovering over it in your editor, you can see its full type:

function getProperty<T, K extends keyof T>(
obj: T, key: K
): T[K]

The return type is inferred as T[K], so K does appear twice! This is a good use of generics: K is related to T, and the return type is related to both K and T.

What about a class?

class ClassyArray<T> {
arr: T[];
constructor(arr: T[]) { this.arr = arr; }

get(): T[] { return this.arr; }
add(item: T) { this.arr.push(item); }
remove(item: T) {
this.arr = this.arr.filter(el => el !== item)
}
}

This is fine since T appears many times in the implementation (I count 5). When you instantiate a ClassyArray, you bind the type variable and it relates the types of all the properties and methods on the class.

This class, on the other hand, fails the test:

class Joiner<T extends string | number> {
join(els: T[]) {
return els.map(el => '' + el).join(',');
}
}

First of all, T only applies to join, so it can be moved down onto the method, rather than the class:

class Joiner {
join<T extends string | number>(els: T[]) {
return els.map(el => '' + el).join(',');
}
}

By moving the declaration of T closer to its use, we make it possible for TypeScript to infer the type of T. Generally this is what you want!

But in this case, since T only appears once, you should make it non-generic:

class Joiner {
join(els: (string|number)[]) {
return els.map(el => '' + el).join(',');
}
}

Finally, why does this need to be a class at all? This noun-ing feels like a Java-ism. Just make it a standalone function:

function join(els: (string|number)[]) {
return els.map(el => '' + el).join(',');
}

How about this function to get the length of any array-like object?

interface Lengthy {
length: number;
}
function getLength<T extends Lengthy>(x: T) {
return x.length;
}

Since T only appears once after its definition, this is a bad use of generics. It could be written as:

function getLength(x: Lengthy) {
return x.length;
}

or even:

function getLength(x: {length: number}) {
return x.length;
}

Or, since TypeScript has a built-in ArrayLike type:

function getLength(x: ArrayLike) {
return x.length;
}

If you've made it this far, you should have a good sense for how to apply the golden rule of generics and how to fix the declarations that break it. As you read and write generic functions, think about whether they follow this rule! If you're having trouble telling or aren't sure how to fix one, tweet at me!

(Credit for this rule goes to TypeScript engineering lead Ryan Cavanaugh. If you like it, be sure to thank him! If you really like it, consider turning it into an eslint rule. Golden ruler is from FreeSvg.)

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 »