Ever since learning about it, I have always thought that generic type is awesome. But now I know that it can be even more awesome!
With generic, a single type can be reused with a variety of other types. Think of
Promise, it can resolve to just about anything. For example, in one case it would resolve a string, while another case expect it to resolve a boolean. As
Promise type is a generic, we can assert the resolved value by passing the type into its generic, like so
Alright, enough of the basic. What if you want to have a generic which can only accept a certain type or interface?
To answer, let’s imagine we are building a very simple bookkeeper app which record transactions. A transaction can be an expense, or an income. You may even add a transfer as another form of transaction, but let’s keep it simple for now.
An expense and an income have a lot in common, the former means cash flowing out while the latter in. Since the use case would be quite similar for both, it would be a proper use of generic type. In this case, we will create an interface to implement the business use case of creating and updating the transactions.
Generic with union
Let’s first see how we can create this by using union type.
It might look like by providing the union type, the generic would validate the type passed in when implementing the interface. But this is incorrect as the union types are just for default. When used, the generic type use the type passed in instead, just like in our example with
Hence, the class below would work without any problem, which is not what we want as we want the generic to only accept a specific kind of types.
Generic with extends
Instead of providing default types, we should use
extends to ensure TypeScript to validate the use of this generic.
extends, we tell TypeScript that this generic should only receive types that implements a certain type, or extends from it. Hence, the previous implementation would not work now as it does not adhere to the required type.
As such, we can be sure that the user who implements this interface would be adhering to the basic types that we have defined.
By doing this, we are guaranteed of two things.
First, any client implementing the contract would work with the constraints of the types we defined. This mean another class depending on this contract can be rest assured that params and result of the methods would be as expected. I.e., dependency for expense transaction would accept expense params and resolve an expense data, and so on.
Second, any changes to the transaction shape itself would have alerted the concrete classes as they have to update their implementation to follow the new contract. So if we require a transaction to also record the location it took place, now all concrete classes would have to adhere to this new type. Although in this example, it would probably better be introduced with some backward compatibility, or as a new contract altogether.