Typescript Generics Golden Rule
If a type parameter only appears in one location, strongly reconsider if you actually need it.
parseYAML example
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 parameter 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!
printProperty example
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.
Joiner Example
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(',');
}Lengthy example
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;
}