Advanced generics in TypeScript. Yandex report

Generics, or parameterized types, allow you to write more flexible functions and interfaces. To go further than parameterization with a single type, you only need to understand a few general principles of generics, and TypeScript will open before you like a box of secrets. AlexandrNikolaichev explained how not to be afraid to nest generics in each other and use automatic type inference in your projects.



- Hello everyone, my name is Alexander Nikolaichev. I work at Yandex.Cloud as a front-end developer, working on Yandex's internal infrastructure. Today I will tell you about a very useful thing, without which it is difficult to imagine a modern application, especially of a large scale. This is TypeScript, typing, a narrower topic - generics, and why they are needed.



First, let's answer the question why TypeScript and what does the infrastructure have to do with it. Our main property of infrastructure is its reliability. How can this be ensured? First of all, you can test.





We have unit and integration tests. Testing is a good standard practice.



You also need to use code review. In addition - collection of errors. If, nevertheless, an error occurs, then a special mechanism sends it, and we can quickly fix something.



How nice it would be not to make mistakes at all. For this, there is typing, which will not allow us to get an error at runtime at all. Yandex uses the industry standard TypeScript. And since the applications are large and complex, we will get this formula: if we have a frontend, typing, and even complex abstractions, then we will definitely come to TypeScript generics. You can't do without them.



Syntax



To conduct a basic educational program, let's first look at the basics of syntax.



A generic in TypeScript is a type that depends on another type.



We have a simple type, Page. We parameterize it with a certain parameter <T>, it is written through angle brackets. And we see that there are some strings, numbers, but <T> is variable.



In addition to interfaces and types, we can apply the same syntax to functions. That is, the same <T> parameter is forwarded to the function argument, and in the response we will reuse the same interface, we will also pass it there.



Our generic call is also written through angle brackets with the desired type, just like when it was initialized.



Similar syntax exists for classes. We throw the parameter into private fields, and we have a kind of getter. But we don't write the type there. Why? Because TypeScript can infer the type. This is a very useful feature of his, and we will apply it.



Let's see what happens when using this class. We create an instance, and instead of our <T> parameter, we pass one of the enumeration elements. We create an enumeration - Russian, English. TypeScript understands that we have passed an element from the enumeration and infers the type lang.



But let's see how type inference works. If instead of enumeration elements we pass a constant from this enumeration, then TypeScript understands that this is not the whole enumeration, not all of its elements. And there will already be a specific value of the type, that is, lang en, English.



If we pass something else, say a string, then it would seem that it has the same meaning as the enumeration. But this is already a string, another type in TypeScript, and we will get it. And if we pass a string as a constant, then instead of a string there will be a constant, a string literal, these are not all strings. In our case, there will be a specific string en.



Now let's see how we can expand this.



We had one parameter. Nothing prevents us from using multiple parameters. All of them are written separated by commas. In the same angle brackets, and we apply them in order - from the first to the third. We substitute the desired values ​​when called.



Let's say a concatenation of numeric literals, some standard type, a concatenation of string literals. They are all simply written down in order.



Let's see how this happens in functions. We create a random function. It randomly gives either the first argument or the second.



The first argument is of type A, the second is of type B. Accordingly, their union is returned: either this or this. First of all, we can explicitly type the function. We indicate that A is a string, B is a number. TypeScript will look at what we have explicitly specified and infer the type.



But we can also use type inference. The main thing is to know that it is not just the type that is inferred, but the smallest possible type for the argument.



Suppose we pass an argument, a string literal, and it must correspond to type A, and the second argument, one, to type B. The minimum possible for a string literal and one is literal A and the same one. TypeScript will output this to us. It turns out such a narrowing of types.



Before moving on to the following examples, we will see how types generally relate to each other, how to use these relationships, how to get order out of the chaos of all types.



Relationship of types



Types can be conventionally considered as a kind of set. Let's look at the diagram, which shows a piece of the whole set of types.



We see that the types in it are connected by some kind of relationship. But which ones? These are partial ordering relationships - which means that a type is always specified with its supertype, that is, a type "above" it, which covers all possible values.



If you go in the opposite direction, then each type can have a subtype, "less" of it.



What are the supertypes of a string? Any joins that include a string. A string with a number, a string with an array of numbers, whatever. Subtypes are all string literals: a, b, c, or ac, or ab.



But it's important to understand that the order is not linear. That is, not all types can be compared. This is logical, and this is what leads to type mismatch errors. That is, a string cannot be simply compared to a number.



And in this order there is a type, as it were, the topmost one - unknown. And the lowest, analogue of the empty set, is never. Never is a subtype of any type. And unknown is a supertype of any type.



And of course there is an exception - any. This is a special type, it ignores this ordering altogether, and is used if we are migrating from JavaScript to not care about the types. It is not recommended to use any from scratch. It's worth doing this if we don't really care about the position of the type in that order.



Let's see what knowledge of this order will give us.



We can restrict parameters to their supertypes. The keyword is extends. We will define a type, generic, which will have only one parameter. But we will say that it can only be a subtype of the string or the string itself. We will not be able to transfer numbers, this will cause a type error. If we explicitly type the function, then in the parameters we can specify only the subtypes of the string or the string - apple and orange. Both strings are concatenation of string literals. Verification passed.





We can also automatically infer types ourselves based on the arguments. If we passed a string literal, then this is also a string. The check worked.



Let's see how to expand these restrictions.



We limited ourselves to just a line. But a string is too simple a type. I would like to work with object keys. To work with them, we first understand how the object keys themselves and their types are arranged.



We have a certain object. It has some kind of fields: strings, numbers, boolean values ​​and keys by name. To get the keys, we use the keyof keyword. We get the union of all key names.



If we want to get the values, we can do it through the square brackets syntax. This is similar to JS syntax. It only returns types. If we pass the entire subset of the keys, then we get the union of all the values ​​of this object in general.



If we want to get a part, then we can specify that - not all keys, but some subset. We expect to receive only those fields that correspond to the specified keys. If we reduce everything to a single case, this is one field, and one key gives one value. So you can get the corresponding field.



Let's see how to use object keys.



It is important to understand that any valid type can be after the extends keyword. Including formed from other generics or using keywords.



Let's see how this works with keyof. We have defined the CustomPick type. In fact, this is almost a complete copy of the Pick library type from TypeScript. What is he doing?



It has two parameters. The second is not just a parameter. It must be the keys of the first. We see that we have it expanding keyof from <T>. Hence, it must be some subset of the keys.



Next, for each key K from this subset, we run around the object, put the same value and specially remove the optionality, minus the question mark with the syntax. That is, all fields will be required.



We look at the application. We have an object, in it the names of the fields. We can take only a subset of them - a, b or c, or all at once. We took a or c. Only the corresponding values ​​are displayed, but we see that the a field has become required, because we, relatively speaking, removed the question mark. We defined this type, used it. Nobody bothers us to take this generic and shove it into another generic.



How does this happen? We have defined another type, Custom. The second parameter expands not keyof, but the result of applying the generic, which we have shown on the right. How does it work, what are we transferring to it at all?



We pass any object and all its keys to this generic. This means that the output will be a copy of the object with all required fields. This chain of nesting a generic into another generic and so on can be continued indefinitely, depending on the tasks, and structure the code. Introduce reusable constructs to generics and so on.



The specified arguments do not have to be in order. Kind of like the P parameter expands the T keys in the CustomPick generic. But no one bothered us to indicate it as the first parameter, and T as the second. TypeScript doesn't go sequentially across parameters. He looks at all the parameters that we have specified. Then he solves a certain system of equations, and if he finds a solution to the types that satisfy this system, then the type check passed.



In this regard, we can derive such a funny generic, in which the parameters expand each other's keys: a - these are the keys b, b - the keys a. It would seem, how can this be, the keys of the keys? But we know that TypeScript strings are actually JavaScript strings, and JavaScript strings have their own methods. Accordingly, any string method name will do. Because the name of a string method is also a string. And from there she has her name.



Accordingly, we can obtain such a restriction, and the system of equations will be resolved if we indicate the required types.



Let's see how this can be used in reality. We use it for the API. There is a site where Yandex applications are deployed. We want to display the project and the service that corresponds to it.



In the example, I took a project to run qyp virtual machines for developers. We know that we have the structure of this object in the backend, we take it from the base. But besides the project, there are other objects: drafts, resources. And they all have their own structures.



Moreover, we want to request not the whole object, but a couple of fields - the name and the name of the service. There is such an opportunity, the backend allows you to pass paths and receive an incomplete structure. DeepPartial is described here. We will learn how to design it a little later. But this means that not the whole object is transmitted, but some part of it.



We want to write some function that would request these objects. Let's write in JS. But if you look closely, you can see typos. In the type of "Projeact", in the paths there is also a typo in the service. Not good, the error will be in runtime.



The TS variant doesn't seem to be much different apart from the paths. But we will show that, in fact, there can be no other values ​​in the Type field besides those that we have on the backend.



The paths field has a special syntax that simply won't let us select other missing fields. We use a function where we simply list the levels of nesting we need and get an object. In fact, getting paths from this function is a concern of our implementation. There is no secret here, she uses a proxy. This is not so important to us.



Let's see how to get the function.





We have a function, its use. There is this structure. First, we want to get all the names. We write a type where the name matches the structure.



Let's say for a project we describe its type somewhere. In our project, we generate taipings from protobuf files that are available in the general repository. Next, we see that we have all the types used: Project, Draft, Resource.



Let's look at the implementation. Let's look at it in order.



There is a function. First, let's see how it is parameterized. Just by these previously described names. Let's see what it returns. It returns values. Why is this so? We have used the square brackets syntax. But since we are passing one string to the type, concatenation of string literals when used is always one string. It is not possible to compose a string that is both a project and a resource at the same time. She is always one, and the meaning is also the same.



Let's wrap everything in DeepPartial. Optional type, optional structure. The most interesting thing is the parameters. We ask them with the help of another generic.



The type with which the generic parameters are parameterized is also the same as the constraint on the function. It can only accept the type of name - Project, Resource, Draft. ID is, of course, a string, we are not interested in it. Here is the type we indicated, one of three. I wonder how the path function works. This is another generic - why don't we reuse it. In fact, all it does is simply create a function that returns an array of any, because our object can have fields of any type, we don't know which ones. In this implementation, we gain control over the types.



If someone found it simple, let's move on to the control structures.



Control constructs



We will consider only two constructions, but they will be enough to cover almost all the tasks that we need.



What are Conditional Types? They are very similar to ternarks in JavaScript, only for types. We have a condition that type a is a subtype of b. If so, then return c. If not, return d. That is, this is a normal if, only for types.



Let's see how it works. We will define a CustomExclude type, which essentially copies the library Exclude. It just throws out the elements we need from the type union. If a is a subtype of b, then return empty, otherwise return a. This is strange when you look at why it works with joins.



A special law comes in handy, which says: if there is a union and we check the conditions using extends, then we check each element separately and then combine them again. This is such a transitive law, only for conditional types.



When we use CustomExclude, we look at each observation item in turn. a expands a, a is a subtype, but return void; b is a subtype of a? No - return b. c is not a subtype of a either, return c. Then we combine what is left, all the plus signs, we get b and c. We threw out a and got what we wanted.



The same technique can be used to get all the keys of a tuple. We know that a tuple is the same array. That is, it has JS methods, but we don't need this, we only need indexes. Accordingly, we simply throw out the names of all methods from all the keys of the tuple and get only the indices.



How do we define our previously mentioned DeepPartial type? This is where recursion is used for the first time. We go through all the keys of the object and look. Is the value an object? If so, apply it recursively. If not, and this is a string or a number, leave it and make all fields optional. It's still a Partial type.



This recursive call and conditional types actually make TypeScript Turing complete. But do not rush to rejoice at this. It pisses you off if you try to do something like this, an abstraction with a lot of recursiveness.



TypeScript monitors this and throws an error even at the level of its compiler. You won't even wait until something is counted there. And for such simple cases, where we have only one call, recursion is quite suitable.



Let's see how it works. We want to solve the problem of patching the object's field. We use a virtual cloud to plan the rollout of applications, and we need resources.



Let's say we took CPU resources, cores. Everyone needs kernels. I've simplified the example, and there are just resources, only kernels, and they are numbers.



We want to make a function that patches them, patches values. Add kernels or subtract. In the same JavaScript, as you might have guessed, there are typos. Here we add a number to a string - not very good.



Almost nothing has changed in TypeScript, but in fact, this control at the IDE level will tell you that you can not pass anything other than this string or a specific number.



Let's see how to achieve this. We need to get such a function, and we know that we have an object of this kind. You need to understand that we are patching only the number and fields. That is, you need to get the name of only those fields where there are numbers. We only have one field, and it is a number.



Let's see how this is implemented in TypeScript.



We have defined a function. It has just three arguments, the object we are patching and the field name. But this is not just a field name. It can only be the name of numeric fields. We will now find out how this is done. And the patcher itself, which is a pure function.



There is a certain impersonal function, a patch. We are not interested in its implementation, but in how to get such an interesting type, to get the keys of not only numeric, but any fields by condition. We have numbers here.



Let us analyze in order how this happens.



We go through all the keys of the passed object, then we do this procedure. Let's see that the object field is a subtype of the desired one, that is, a numeric field. If yes, then it is important that we write not the field value, but the field name, otherwise, in general, nothing, never.



But then such a strange object turned out. All numeric fields began to have their names as values, and all non-numeric fields became empty. Then we take all the values ​​of this strange object.



But since all values ​​contain emptiness, and emptiness collapses when combined, only those fields remain that correspond to numeric ones. That is, we got only the required fields.



The example shows: there is a simple object, the field is one. Is this a number? Yes. The field is a number, is it a number? Yes. The last line is not a number. We get only the necessary, numeric fields.



With this sorted out. I left the most difficult one for last. This is the type inference - Infer. Capturing a type in a conditional construct.





It is inseparable from the previous topic, because it only works with a conditional construct.



What does it look like? Let's say we want to know the elements of an array. A certain type of array came, we would like to know a specific element. We look: we received some kind of array. It is a subtype of the array from the variable x. If yes - return this x, array element. If not, return the emptiness.



In this condition, the second branch will never be executed, because we parameterized the type with any array. Of course, it will be an array of something, because an array of any cannot but have elements.



If we pass an array of strings, then a string is expected to be returned to us. And it is important to understand that not just the type is defined here. It is visually clear from the array of strings: there are strings. But with a tuple, everything is not so simple. It is important for us to know that the smallest possible supertype is being determined. It is clear that all arrays are, as it were, subtypes of an array with any or with unknown. This knowledge does not give us anything. It is important for us to know the minimum possible.



Suppose we are passing in a tuple. In fact, tuples are also arrays, but how do we tell what elements this array has? If there is a string tuple of a number, then it is actually an array. But the element must be of the same type. And if there is both a string and a number, then there will be a union.



TypeScript will output this, and we will get exactly the concatenation of a string and a number for such an example.



You can use not only capture in one place, but also as many variables as you want. Let's say we define a type that simply swaps the elements of the tuple: the first with the second. We grab the first element, the second and swap them.



But in fact, it is not recommended to flirt too much with it. Usually, for 90% of tasks, only one type of capture is enough.





Let's see an example. Objective: you need to show, depending on the state of the request, either a good option or a bad one. Here are screenshots from our application deployment service. An entity, ReplicaSet. If the request from the backend returned an error, you need to render it. At the same time, there is an API for the backend. Let's see what Infer has to do with it.



We know that we are using, firstly, redux, and, secondly, redux thunk. And we need to transform the library thunk to be able to do this. We have a bad way and a good one.



And we know that a good way to go in extraReducers in the redux toolkit looks like this. We know that there is PayLoad, and we want to pull out the custom types that came to us from the backend, but not only, but also information about a good or bad request: is there an error or not. We need a generic for this output.



I'm not making a comparison about JavaScript because it doesn't make sense. In JavaScript, in principle, you cannot control types in any way and rely only on memory. There is no bad option here, because there simply isn't one.



We know we want this type. But we do not just have an action. We need to call dispatch with this action. And we need this view, where we need to display an error by the request key. That is, you need to mix such additional functionality on top of redux thunk using the withRequestKey method.



We have this method, of course, but we also have the original API method, getReplicaSet. It is written somewhere and we need to override the redux thunk using some kind of adapter. Let's see how to do it.



We need to get a function like this. It's a thunk with so much extra functionality. It sounds scary, but do not be alarmed, now we will disassemble it on the shelves so that it is clear.



There is an adapter that extends the original library type. We simply mix in an additional withRequestKey method and a custom call to this library type. Let's see what the main feature of the generic is, what parameters are used.



The first is just our API, an object with methods. We can do getReplicaSet, get projects, resources, it doesn't matter. We are using a specific method in the current method, and the second parameter is just the name of the method. Next, we use the parameters of the function we are requesting, we use the Parameters library type, this is a TypeScript type. And similarly, we use the ReturnType library type for the backend response. This is for what the function returned.



Then we just pass our custom output into the AsyncThunk type that the library provided us. But what is this conclusion? This is another generic drug. In fact, it looks simple. We save not only the response from the server, but also our parameters, what we have passed. Just to keep track of them in Reducer. Next we look at withRequestKey. Our method just adds a key. What does he return? The same adapter because we can reuse it. We don't have to write withRequestKey at all. This is just additional functionality. It wraps and recursively returns the same adapter to us, and we pass the same thing there.



Finally, let's see how to output to Reducer what this thunk returned to us.





We have this adapter. The main thing is to remember that there are four parameters: API, API method, parameters (input) and output. We need to get a way out. But we remember that we have a custom output: both the server response and the request parameter.



How can I do this with Infer? We see that this adapter is supplied to the input, but it is generally any: any, any, any, any. We have to return this type, it looks like this, the server response and the request parameters. And we are looking at where the entrance should be. On the third. This is where we place our type capture. We get the entrance. Likewise, exit is in fourth place.



TypeScript is based on structured typing. He disassembles this structure and understands that the entrance is here, in third place, and the exit is in fourth. And we return the types we want.



So we achieved type inference, we have access to them already in the Reducer itself. It's basically impossible to do this in JavaScript.



All Articles