What makes a good generic and what makes a bad one?
A good generic lets type information flow through it, and you won't lose type information.
In TypeScript, understanding how to write good generics is essential. But what distinguishes a good generic from a bad one? Let's delve into the nuances.
interface HasId {
id: string
}
function example1<T extends HasId[]>(list:T) {
return list.pop()
}
function example2<T extends HasId>(list:T[]) {
return list.pop()
}
class Payment implements HasId {
id = '0123'
}
class Invoice implements HasId {
id = 'INVOICE_099'
}
const result1 = example1([new Payment(), new Invoice(), new Payment()])
// ^ const result1: HasId | undefined
const result2 = example2([new Payment(), new Invoice(), new Payment()])
// ^ const result2: Payment | Invoice | undefined
In this scenario, example2 emerges as a superior choice in terms of generic implementation, while example1 falls short. But why?
The crucial difference lies in the preservation of type information.
Consider the return types of result1 and result2: HasId | undefined
and Payment | Invoice | undefined
, respectively.
-
result1
yields the type HasId, which merely guarantees the existence of an id property. This type lacks the specificity required to treat objects as instances of a particular class. -
Conversely, result2 offers a union type of Payment and Invoice, providing more precise information about the returned objects.
The key takeaway here is to specify constraints in the simplest manner possible.
In example2, we explicitly define two constraints for the argument type: T and []. T extends HasId, ensuring the presence of an id property, while [] indicates an array structure, allowing for a clearer representation of the input.
On the other hand, example1 lacks this clarity, leading to a loss of type information.
Remember, "A good generic lets type information flow through it".