TypeScript magic school: generics and type extensions

Original author: Idan Dardikman
  • Transfer
  • Tutorial
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 are the benefits he receives. 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 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 a simple design as any, he had to pay for it with many hours of hard debugging.



In the end, he came to the conclusion 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 type is anyvery difficult. And sometimes it seems that any- this is the only reasonable solution to a certain problem.

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

Generics


Suppose we are working on a database of a certain educational institution. We wrote a very convenient helper function getBy. In order to get an object representing a student by his name, we can use the command of the form getBy(model, "name", "Harry"). Let's 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 in other languages ​​with strict typification, we can use generics, which are also called “generic types”, “universal types”, “generalizations”.

A generic is like an ordinary variable, but instead of a certain value, it contains a type definition. Rewrite the code of our function so that instead of typeStudentshe would use a generic type T.

functiongetBy<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 ideally suited for reuse while the type safety is still on our side. Notice how in the last line of the above code snippet the type is explicitly set Studentwhere the generic is used T. This is done in order 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 "name"there will be"naem"? The function will behave as if the desired student is simply not in the database, and, what is most unpleasant, will not give any errors. This can lead to long-term debugging.

In order to guard against such errors, we introduce another generic type P. In this case, it is necessary to Pbe a type key T, therefore, if a type is used here Student, then it is necessary that it Pbe a 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 oftype'"naem"'isnot assignable to parameter oftype'"name" | "age" | "hasScar"'.

Using generics and keywords keyofis 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, the work on the function getBywe have not finished. 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 a type P, we can dynamically infer a type for the third argument. Type of the third argument, in the end, will be T[P]. As a result, if Tis Student, a Pis "age", thenT[P]will match the type number.

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 oftype'"17"'isnot assignable to parameter oftype'number'.
const anotherResult = getBy(students, "hasScar", "true")
// Error: Argument oftype'"true"'isnot assignable to parameter oftype'boolean'.
const yetAnotherResult = getBy(students, "name", "Harry")
// А тут уже всё правильно

I hope that now you have a completely clear understanding of how to use generics in TypeScript, but if you want to experiment with everything you want to experiment with the code discussed 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 object window, or expand the behavior of some external library like Express. In both cases, you cannot directly influence the object you want to work with.

We will consider the solution of a similar problem on the example of adding a function you already know getByto the prototype Array. 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 prototype Array, the compiler will not like it very much:

Array.prototype.getBy = function <T, PextendskeyofT>(
    this: T[],
    prop: P,
    value: T[P]
): T | null{
  returnthis.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 construction as any, we negate all that we have achieved. The compiler will be silent, but you can forget about safe work with types.

It would be better to extend the type Array, 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:

interfaceWand {
  length: number
}
interfaceWand {
    core: string
}
const myWand: Wand = { length: 11, core: "phoenix feather" }
// Отлично работает!

And this one is not:

interfaceWand {
  length: number
}
interfaceWand {
    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 interface Array<T>and add a function to it getBy.

interfaceArray<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 {
  returnthis.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 interface Array, you will need access to the global scope. This can be done by putting the type definition inside declare global. For example - like this:

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

If you are going to expand the interface of the external library, then you will most likely need access to the namespace ( namespace) of this library. Here is an example of how to add a field userIdto Requestfrom a library Express:

declare global {
  namespaceExpress {
    interfaceRequest {
      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?


Also popular now: