, and the recording of the mitap broadcast is here
Our front-end applications run on a React + Redux bundle. Redux data flow looks simplistic:
There are action creators - functions that return action. The actions fall into the reducer, the reducer creates a new stop based on the old one. Components that, in turn, can dispute new actions, are signed on the line - and everything repeats.
This is how the action creator looks like in the code:
This is just a function that returns an action - an object that necessarily has a string type field and some data (optional).
This is what a typical reducer looks like:
This is a normal switch-case that looks at the type field of an action and generates a new one. In the example above, he simply adds the values of the properties of the action.
What if we accidentally make a mistake in writing a reducer? For example, like this, we mix up the properties of different actions:
To begin with, let's write with our hands “in the forehead” the types for our action games - Action1Type and Action2Type. And then, combine them into one union-type to use in the reducer. The approach is simple and clear, but what if the data in the actions will change as the application develops? Do not change the types each time manually. Rewrite them as follows:
The typeof operator will return us the type of action creator, and ReturnType will give us the return type of the function - i.e. type of action. The result will be the same as the slide above, but no longer manually - when you change the actions, the union-type ActionTypes will be updated automatically. Great! We write it to the reducer and ...
And we immediately get errors from the script. Moreover, the errors are not entirely clear - the bar property is not in the foo action, and foo is not in the bar ... It seems that this is how it should be? It seems that something is messed up. In general, the approach "in the forehead" is not expected to work.
But this is not the only problem. Imagine that over time our application will grow, and we will have a lot of action games. A lot.
What would our common type look like to them in this case? Probably something like this:
And if we consider that the actions will be added and deleted, we will have to support all this manually - add and delete types. This also does not suit us at all.What to do? Let's start with the first problem.
So, we have a couple of action creators, and the general type for them is the union of automatically derived action types. Each action has a type property, and it is defined as a string. This is the root of the problem. To distinguish one action from another, we need each type to be unique and only take one unique value.
This type is called literal. The literal type is of three kinds — numeric, string, and boolean.
For example, we have the type onlyNumberOne and we specify that a variable of this type can only equal the number 1. Assign 2 - and get a typscript error. String works in a similar way - only one specific string value can be assigned to a variable. Well, boolean is either true or false, without uncertainty.
How to save such a type, not allowing it to become a string? We will use generics. Generic is such an abstraction over types. Suppose we have a useless function that takes an argument as input and returns it without any changes. How can it be typed? Write any, because it can be absolutely any type? But if some logic is present in the function, type conversion can occur, and, for example, the number can turn into a string, and the any-any combination will skip it. Not suitable.
The generic will help us out of this situation. The record above means that we feed an argument of some type T, and the function will return exactly the same type T to us. We do not know whether it will be a number, a string, a boolean or something else - but we can guarantee that it will be exactly the same type. This option suits us.
We will develop the concept of generics a little. We need to handle not all types at all, but a specific string literal. For this there is the extends keyword:
The “T extends string” means that T is a type that is a subset of the type string. It is worth noting that this only works with primitive types - if we used the type of an object with a certain set of properties instead of string, it would mean that T is OVER a set of this type.
Below are examples of using functions typed with extends and generics:
- Argument of type string - the function will return a string
- Literal string argument - the function will return a literal string
- If the argument does not look like a string, for example, a number, or an array, the typecript will generate an error.
Well, in general it works.
We substitute our function in the type of action - it returns exactly the same string type, but only it is no longer a string, but a literal string, as it should be. Putting the union type, typing the reducer, that's all right. And if we make a mistake and write the wrong properties, the typing script will give us not two, but one, logical and understandable error:
Let's go a little further and abstract from the type string. We write the same typing, using only two generics - T and U.Now we have a certain type of T will depend on another type of U, instead of which we can use anything - even string, at least number, even boolean. This is implemented using the wrapper function:
And finally: the problem described hung for a very long time as an issue on the githaba, and finally in Typescript version 3.4, the developers presented us with a solution - const assertion. He has two forms of writing:
Thus, if you have fresh typescript, you can simply use or as const in actions, and the literal type will not turn into a string. In older versions, you can use the method described above. So, we now have two solutions for the first problem. But the second remains.
We still have a lot of different action games, and despite the fact that we now know how to properly handle their types, we still don’t know how to automatically put them together. We can write union manually, but if actions are deleted and added, we still need to manually remove and add them to the type as well. This is wrong.
Where to begin? Suppose we have action creators imported together from a single file. We would like to bypass them in turn, display the types of action games and assemble them into one union-type. And most importantly, we would like to do this automatically, without manually editing the types.
Let's start by traversing the action creators. For this, there is a special mapped type, which describes the key-value collections. Here is an example:
Here you create a type for an object, the keys of which are option1 and option2 (from the Keys set), and the values - true or false. In a more general case, this can be represented as a type mapOfBool - an object with some sort of row keys and boolean values.
Good. But how can we check in general that the object, and not some other type, was given to us at the entrance? This will help us conditional type - simple ternarnik in the world of types.
In this example we check: type T has something in common with string? If yes, then return the string, and if not, return the type never. This is such a special type that will always return an error to us. String literal satisfies the ternary condition. Here are some code examples:
If we indicate something different in generics that is not like string, typescript will give us an error.
With the crawling and checking figured out, it remains only to get the types and combine them into a union. With this help us infer - type inference typescript. Infer usually lives in the conditional type, and does something like this: goes through all the key-value pairs, tries to infer the type of the value, and compares it with the others. If the value types are different, they are unionized. Just what we need!
Well, now it remains to put all this together.
It turns out this design:
The logic is approximately as follows: If T is like an object that has certain string keys (action creators), and they have values of some type (a function that returns action to us), then we will try to circumvent these pairs, deduce the type of these values and reduce their general type. And if something goes wrong - throw out a special error (type never).
It is difficult only at first glance. In fact, everything is quite simple. It is worth paying attention to an interesting feature - due to the fact that each action has a unique type field, the types of these actions will not stick together, and we will have a full-fledged union-type at the output. Here’s how it looks in code:
We import the action creators as actions, take them ReturnType (type of return value - actions), and collect with the help of our special type. It turns out just what was required.
What is the result? We got a union of literal types for all actions. When adding a new action, the type is updated automatically. As a result - we get a full-fledged strict typing of action games, now it’s impossible to make a mistake. Well, on the way, you learned about generics, conditional type, mapped type, never and infer - even more information about these tools can be obtained here