Generics and Augmentation will Make You a TypeScript Wizard
This is the second post in my TypeScript series; the first one is here.
As I mentioned before, TypeScript is terrific. When I had only started coding in TS, I loved the free and allowing nature of the language — the more you give, the more you get. I only used type annotations every now and then. Sometimes I got nice auto-completions and hints from the compiler, but mostly I didn’t.
With time, I learned that whenever I bypassed the compiler, I created a runtime error waiting to happen. Every time I sprinkled a little as any
to make the errors go away, I paid for it later with hours of painstaking debugging.
So, I learned not to. I began to make friends with the compiler and pay attention when it was annoyed by the code I wrote. The compiler sees our flaws and alerts us before we can cause any real damage. As a developer, I realized, the compiler is the best friend I’ll ever have, because it protects me from myself.
“It takes a great deal of bravery to stand up to our enemies, but just as much to stand up to our friends.” A. P. Dumbledore
However good a friend the compiler may be, it’s not always easy to please. Avoiding the any
might be tricky from time to time, and sometimes it seems like any
is the only reasonable solution. In this article, I’ll tackle two of those cases, and show you how to avoid any
Using Generics
Say we are working with a school database, and we have written a nice utility function called getBy
. To get a student object by name, we can run getBy(model, "name", "Harry")
. Let’s take a look at how this might look like (for the sake of simplicity, I implemented the DB model as a simple array).
So, we have a decent function, but it has no type annotations, and no types means no safety — so let’s fix that:
That’s much better! Now our compiler knows the type of the result, and this will help us later on. However, to gain this type-safety, we sacrificed the reusability of our function. What if we want to use it to retrieve other entities in the future? There’s got to be a better way.
And there is! In TypeScript, as in other strongly typed languages, we can use generic types. A generic is like a variable, but instead of holding a value, it contains a type definition. Let’s refactor our function to use a generic type T
instead of Student
.
Sweet! Now our function is completely reusable, and we still enjoy the type safety. Note how on line 5, I am explicitly informing the compiler that I will use the type Student
as the generic T
— I did that for the sake of clarity, but the compiler can actually infer that on its own, so it will not appear in the next examples.
Now we have a solid, reusable util function, but we can still do better. What happens if I make a typo and write "naem"
as my second parameter? It will fail silently. My system will act as if this student doesn’t exist, and I will spend hours debugging.
To do that, I will introduce a new generic type P
. I want P
to be a key of the type T
, so if I used Student
, I want P
to be "name"
, "age"
or "hasScar"
. Here is how it can be done:
Using generic types and with keyof
is an extremely powerful technique. If you’re coding in an IDE that supports TypeScript, you will get autocompletion as you type the arguments, which is very handy.
But we’re not done yet. There’s still the third argument to our function sitting there typeless — this is unacceptable. Up until now, we had no way of knowing in advance what type it should be, as it depends on whatever we pass as the second argument. But now that we have the type P
, we can dynamically infer it. The type of the third argument is therefore T[P]
, so if T
stands for Student
and P
stands for "age"
, then T[P]
will be of type number
.
At this point, I hope you have a crystal clear understanding of the usage of generics, but if you want to play around yourself, go ahead and check out the full code example I wrote in the TypeScript playground here.
Augmenting Existing Types
Sometimes, we might face the need to add data or functionality to interfaces that are out of our control. It might be that you wish to change a standard object, like adding a property to the window
object, or augment the behavior of some external library like Express
. In both cases, you can-not change the type definition of the objects you’re working on directly.
For this demonstration, we will add our function getBy
to the Array’s prototype, so that we’ll have a cleaner syntax. Whether this is a good idea or not is irrelevant at this point, since what we’re after is learning the technique.
When we try to add our function to the Array prototype, we can see that the compiler gets mad at us:
If we try to satisfy the compiler by writing as any
here and there, we’ve lost everything we worked for — the compiler will shut, but no more type safety for us.
A better approach would be to augment the type of Array, but first, let’s talk about how TypeScript handles cases of two interfaces with the same type. The answer is simple — if possible, it will merge the definitions, if not, it will raise an error.
So, this works:
And this does not:
Now that we understand that, we face a rather easy task. All we have to do is declare an interface Array<T>
and add to it the function getBy
.
Important note: Most of the time, you will probably be coding in module files, so to change the Array
interface you’ll have to access the global scope. This can easily be done by placing your type definition inside declare global
, like this:
If you wish to augment an interface of an external library, you’d probably need to access this library’s namespace
. Here is an example of how to add a userId
field to Express's Request
:
So… that’s about it. As before, you can play around with the example’s code here to gain more confidence.
If you’ve come this far and learned something new, you can show your appreciation by clapping for this article a few dozen times so other people will see it and learn too. And if you’d like to read more of the stuff I write go ahead and press the ‘follow’ button.