📜 ⬆️ ⬇️

TypeScript magic school: generics and type extensions

The author of the article, the translation of which we are publishing today, says that TypeScript is simply amazing. When he first started using TS, he terribly liked the freedom that is inherent in this language. The more power a programmer puts into his work with TS-specific mechanisms, the more significant his benefits are. Then he used type annotations only periodically. Sometimes he used the code auto-completion capabilities and compiler hints, but mostly relied only on his own vision of the problems he was solving.

Over time, the author of this material realized that every time he bypasses the errors detected at the compilation stage, he lays in his code a time bomb that can explode during program execution. Every time he “struggled” with errors, using the simple as any construction, he had to pay for this with many hours of hard debugging.


')
In the end, he concluded that it is better not to do so. He became friends with the compiler, began to pay attention to his tips. The compiler finds problems in the code and reports them long before they can cause real harm. The author of the article, looking at himself as a developer, realized that the compiler is his best friend, since he protects him from himself. How not to recall the words of Albus Dumbledore: “It takes a lot of courage to take a stand against its enemies, but not less it takes a lot to take a stand against its friends.”

No matter how good the compiler is, it’s not always easy to please. Sometimes avoiding the use of the any type is very difficult. And sometimes it seems that any is the only reasonable solution to a certain problem.

This material is dedicated to two situations. By avoiding the use of the any type in them, you can ensure the type safety of the code, open up opportunities for its reuse and make it intuitive.

Generics


Suppose we are working on a database of a certain educational institution. We have written a very getBy helper function. In order to get an object representing a student by its name, we can use the command like getBy(model, "name", "Harry") . Take a look at the implementation of this mechanism (here, in order not to complicate the code, the database is represented by a regular array).

 type Student = { name: string; age: number; hasScar: boolean; }; const students: Student[] = [ { name: "Harry", age: 17, hasScar: true }, { name: "Ron", age: 17, hasScar: false }, { name: "Hermione", age: 16, hasScar: false } ]; function getBy(model, prop, value) {   return model.filter(item => item[prop] === value)[0] } 

As you can see, we have a good function, but it does not use type annotations, and their absence also means that such a function cannot be called type safe. Fix it.

 function getBy(model: Student[], prop: string, value): Student | null {   return model.filter(item => item[prop] === value)[0] || null } const result = getBy(students, "name", "Hermione") // result: Student 

So our function already looks much better. The compiler now knows the type of result expected from it, it will be useful to us later. However, in order to achieve safe working with types, we donated features to reuse the function. What if we ever need to use it to get some other entity? It cannot be that this function cannot be improved in any way. And indeed it is.

In TypeScript, as well as in other languages ​​with strict typification, we can use generics, which are also called “generic types”, “universal types”, “generalizations”.

A generic is similar to a regular variable, but instead of a certain value, it contains a type definition. We rewrite the code of our function so that instead of the type Student it would use the universal type T

 function getBy<T>(model: T[], prop: string, value): T | null {   return model.filter(item => item[prop] === value)[0] } const result = getBy<Student>(students, "name", "Hermione") // result: Student 

Beauty! Now the function is ideal for reuse while the type safety is still on our side. Notice how in the last line of the above code snippet, the type Student explicitly set where generic T . This is done to make the example as clear as possible, but the compiler, in fact, can independently deduce the required type, so in the following examples we will not make such type specifications.

So now we have a reliable auxiliary function suitable for reuse. However, it can still be improved. What if an error is made when entering the second parameter and instead of "name" there will be "naem" ? The function will behave as if the desired student is simply not in the database, and, most worryingly, will not give any errors. This can lead to long-term debugging.

In order to protect against such errors, we introduce another universal type, P In this case, it is necessary that P be a key of type T , therefore, if the type of Student used here, then it is necessary that P be the string "name" , "age" or "hasScar" . Here's how to do it.

 function getBy<T, P extends keyof T>(model: T[], prop: P, value): T | null {   return model.filter(item => item[prop] === value)[0] || null } const result = getBy(students, "naem", "Hermione") // Error: Argument of type '"naem"' is not assignable to parameter of type '"name" | "age" | "hasScar"'. 

Using generics and the keyof is a very powerful technique. If you are writing programs to an IDE that supports TypeScript, then by entering arguments, you can take advantage of the auto-completion features, which is very convenient.

However, we have not yet completed work on the getBy function. It has a third argument, the type of which we have not yet specified. It does not suit us at all. Until now, we could not know in advance about what type it should be, since it depends on what we pass as the second argument. But now, since we have type P , we can dynamically infer the type for the third argument. The type of the third argument will be T[P] . As a result, if T is Student , and P is "age" , then T[P] will correspond to the number type.

 function getBy<T, P extends keyof T>(model: T[], prop: P, value: T[P]): T | null {   return model.filter(item => item[prop] === value)[0] || null } const result = getBy(students, "age", "17") // Error: Argument of type '"17"' is not assignable to parameter of type 'number'. const anotherResult = getBy(students, "hasScar", "true") // Error: Argument of type '"true"' is not assignable to parameter of type 'boolean'. const yetAnotherResult = getBy(students, "name", "Harry") //      

I hope that now you have formed a completely clear understanding of how to use generics in TypeScript, but if you want to learn everything very well, you want to experiment with the code reviewed here, you can look here .

Extending existing types


Sometimes we may encounter the need to add data or functionality to interfaces, the code of which we cannot change. You may need to change the standard object, say, add some property to the window object, or extend the behavior of some external library like Express . Both in that and in other cases you have no opportunity to directly influence the object with which you want to work.

We will look at solving a similar problem using the example of adding the getBy function you already know to the Array prototype. This will allow us, using this function, to build more accurate syntactic constructions. At the moment we are not talking about whether it is good or bad to expand standard objects, since our main goal is to study the approach under consideration.

If we try to add a function to the Array prototype, the compiler will not like it very much:

 Array.prototype.getBy = function <T, P extends keyof T>(   this: T[],   prop: P,   value: T[P] ): T | null { return this.filter(item => item[prop] === value)[0] || null; }; // Error: Property 'getBy' does not exist on type 'any[]'. const bestie = students.getBy("name", "Ron"); // Error: Property 'getBy' does not exist on type 'Student[]'. const potionsTeacher = (teachers as any).getBy("subject", "Potions") //  ...   ? 

If we try to reassure the compiler, periodically using the as any construct, we negate everything we have achieved. The compiler will be silent, but you can forget about safe work with types.

It would be better to extend the Array type, but before doing this, let's talk about how TypeScript handles situations of presence in the code of two interfaces of the same type. Here is a simple scheme of action. Ads will, if possible, be merged. If you can not combine them - the system will give an error.

So this code works:

 interface Wand { length: number } interface Wand {   core: string } const myWand: Wand = { length: 11, core: "phoenix feather" } //  ! 

And this one is not:

 interface Wand { length: number } interface Wand {   length: string } // Error: Subsequent property declarations must have the same type.  Property 'length' must be of type 'number', but here has type 'string'. 

Now, having dealt with this, we see that we are faced with a rather simple task. Namely, all we have to do is declare the Array<T> interface and add the getBy function to it.

 interface Array<T> {  getBy<P extends keyof T>(prop: P, value: T[P]): T | null; } Array.prototype.getBy = function <T, P extends keyof T>(   this: T[],   prop: P,   value: T[P] ): T | null { return this.filter(item => item[prop] === value)[0] || null; }; const bestie = students.getBy("name", "Ron"); //   ! const potionsTeacher = (teachers as any).getBy("subject", "Potions") //     

Note that you will probably write most of the code in the module files, so in order to make changes to the Array interface, you will need access to the global scope. This can be done by putting the type definition inside declare global . For example - so:

 declare global {   interface Array<T> {       getBy<P extends keyof T>(prop: P, value: T[P]): T | null;   } } 

If you are going to expand the interface of an external library, then you will most likely need access to the namespace this library. Here is an example of how to add the userId field to the Request from the Express library:

 declare global { namespace Express {   interface Request {     userId: string;   } } } 

You can experiment with the code in this section here .

Results


In this article, we looked at techniques for using generics and type extensions in TypeScript. We hope that what you learned today will help you in writing reliable, understandable and type-safe code.

Dear readers! How do you feel about type in TypeScript?

Source: https://habr.com/ru/post/426729/


All Articles