Deriving an Action type using Typescript

    Hello! My name is Dmitry Novikov, I am a javascript developer at Alfa-Bank, and today I will tell you about our experience in deriving Action types using Typescript, what problems we encountered and how we solved them.

    This is a transcript of my report on Alfa JavaScript MeetUp. You can see the code from the presentation slides here , and the recording of the mitap broadcast here .

    Our front-end applications run on a bunch of React + Redux. Redux data flow simply looks like this:


    There are action creators - functions that return an action. Actions fall into the reducer, the reducer creates a new side based on the old one. Components are signed to the party, which in turn can dispatch new actions - and everything repeats.

    This is how the action creator looks in the code:


    This is just a function that returns an action - an object that must have a type string field and some data (optional).

    This is what a typical reducer looks like:


    This is a regular switch-case that looks at the type field of an action and generates a new side. In the example above, it simply adds property values ​​from the action there.

    What if we accidentally make a mistake in writing a reducer? For example, like this, we will interchange the properties of different actions:


    Javascript does not know anything about our actions and considers such code to be absolutely valid. However, it will not work as intended, and we would like to see this error. What will help us if not Typescript? Let's try to typify our actions.


    To begin with, we’ll write with our hands “on the forehead” the types for our actions - Action1Type and Action2Type. And then, combine them into one union type to use in the reducer. The approach is simple and straightforward, but what if the data in the actions changes during the development of the application? Do not change the types manually each time. We rewrite them as follows:


    The typeof operator will return the action creator type to us, and ReturnType will give us the type of the return value of the function - i.e. type of action. As a result, it will turn out the same as the slide above, but no longer manually - when changing actions, the union-type ActionTypes will be updated automatically. Wow! We write it in the reducer and ...


    And immediately we get errors from the time script. Moreover, the errors are not entirely clear - the bar property is absent in the action foo, and foo is absent in the bar ... It seems to be the way it should be? Something seems to be messed up. In general, the forehead approach does not work as expected.

    But this is not the only problem. Imagine that over time, our application will grow, and we will have a lot of actions. Lots of.


    What would our common type look like for them in this case? Probably something like this:


    And if we take into account 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 common 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 accept one unique value.



    This type is called literal. The literal type is of three types - numeric, string and boolean.



    For example, we have the type onlyNumberOne and we specify that a variable of this type can only be equal to 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 ambiguity.

    Generic


    How to save this type without allowing it to turn into a string? We will use generics. Generic is such an abstraction over types. Suppose we have a useless function that takes an input as an argument and returns it without changes. How can I type it? Write any, because it can be absolutely any type? But if some kind of logic is present in the function, then type conversion can occur, and, for example, a number can turn into a string, and any-any combination will skip this. Not suitable.



    A generic will help us get out of this situation. The entry above means that we are passing an argument of a certain type T, and the function will return exactly the same type of T. We don’t know which one it will be - a number, a string, boolean or something else - but we can guarantee that it will be exactly the same type. This option suits us.

    Let's develop the concept of generics a bit. We need to handle not all types in general, but a specific string literal. There is an extends keyword for this:



    The entry “T extends string” means that T is a type that is a subset of the string type. It is worth noting that this only works with primitive types - if instead of using string we would use an object type with a specific set of properties, then it would mean that T is OVER a set of this type.

    Below are examples of using a function typed with extends and generics:


    • Argument of type string - the function will return string
    • An argument of type literal string - the function will return literal string
    • If the argument does not look like a string, for example, a number, or an array, the script will give an error.


    Well, and overall it works.


    We substitute our function in the type of the action - it returns the exact same string type, but it is no longer a string, but a literal string, as it should be. We collect union type, we typify a reducer - everything is all right. And if we make a mistake and write the wrong properties, the time script will give us not two, but one logical and understandable error:


    Let's go a little further and abstract from the string type. We will write the same typification, only using two generics - T and U. Now we have a certain type of T that will depend on another type of U, instead of which we can use anything - at least string, at least number, at least boolean. This is implemented using the wrapper function:


    And finally: the described problem hung for a long time as issue on the github, and finally, in Typescript version 3.4, the developers presented us with a solution - const assertion. It has two forms of writing:


    Thus, if you have a fresh typescript, you can simply use either as const in the actions and the literal type will not turn into a string. In older versions, you can use the method described above. It turns out that we now have as many as two solutions to the first problem. But the second remains.



    We still have a lot of different actions, and despite the fact that we now know how to handle their types correctly, we still do not know how to automatically assemble them together. We can write union manually, but if actions are deleted and added, we still need to manually delete and add them in the type. It is not right.


    Where to begin? Suppose we have action creators imported together from a single file. We would like to go around them one by one, deduce the types of their actions and collect them into one union type. And most importantly, we would like to do this automatically, without manually editing types.


    Let's start by going around the action creators. To do this, there is a special mapped type that describes the key-value collections. Here is an example:


    This creates a type for an object whose keys are option1 and option2 (from the Keys set), and the values ​​are true or false. In a more general version, this can be represented as a type of mapOfBool - an object with some kind of row keys and Boolean values.

    Good. But how can we verify that it is an object that is given to us at the input, and not some other type? This will help us conditional type - a simple ternary in the world of types.


    In this example, we check: type T has something in common with string? If so, then return string, and if not, return never. This is such a special type that will always return us an error. String literal satisfies the ternary condition. Here are some code examples:


    If we specify something in the generics that is not like string, typescript will give us an error.

    We figured out the workaround and check, it remains only to get the types and combine them into a union. This will help us with infer type inference in typescript. Infer usually lives in a conditional type, and does something like this: it goes through all the key-value pairs, tries to infer the value type and compares it with the others. If the types of values ​​are different, it combines them into a union. Just what we need!


    Well, now it remains to put it all together.

    It turns out this design:


    The logic is approximately the following: If T looks like an object that has some string keys (action creators), and they have values ​​of some type (a function that returns an action to us), then try to bypass these pairs, deduce the type of these values and reduce their common 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 get a full union type at the output. Here's what it looks like in code:


    We import the action creators as actions, take their ReturnType (the type of the return value is actions), and collect using our special type. It turns out just what was required.


    What is the result? We got union from literal types for all actions. When a new action is added, the type is updated automatically. As a result, we get a full-fledged strict typing of actions, now we can’t make a mistake. Well, along the way we learned about generics, conditional type, mapped type, never and infer - even more information about these tools can be found here .

    Also popular now: