How to use Typescript's type programming to automatically infer the type of Redux reducer

How to use Typescript's type programming to automatically infer the type of Redux reducer

Today, the use of ts has almost become a front-end political correctness. Although the automatic type derivation of ts is already very powerful, but limited by the js language itself, we still need to write many types by hand and specify them manually.

For example, when using ts to write the reducer of redux, how do we ensure the correctness of the action type?

Simple but incorrect

The simplest, we can write


type Action = {
	type: string;
  payload: unknown;
}

function reducer(state: {}, action: Action) {
 //do something 
}

 

This writing method is very simple, but writing ts in this way cannot help us deduce the payload type. If we want to use the payload attribute, we can only manually use the as syntax conversion. In this case, we rely on human eye derivation to ensure the correctness of the code. This derivation is very unreliable and almost completely deviates from our original intention of using ts!

Recognizable joint type

In the real world, we classify the most common means to fight the label , if ts also have this tagging behavior, be nice too.

To understand the characteristics of the next ts of a discriminated union type it

Here is an example of ts official website borrowed

interface Square {
    kind: "square";
    size: number;
}

interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}

interface Circle {
    kind: "circle";
    radius: number;
}

 

Note that these three interface of .kind property types! Not a string, but a literal type.

The literal types in ts include number and string . The literal type limits the value that a variable can be assigned to!

let hiWorld: "Hello World" = "Hi World";
//Error:  "Hi World" "Hello World" 
 

Just now, we mentioned the label. In fact, this .kind property is the label we put on similar but different objects! By distinguishing this label, we will be able to obtain a specific value corresponding to the type of !

type Shape = Square | Rectangle | Circle;

function area(s: Shape): number {
    //In the following switch statement, the type of s is narrowed in each case clause
    //according to the value of the discriminant property, thus allowing the other properties
    //of that variant to be accessed without a type assertion.
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.width * s.height;
        case "circle": return Math.PI * s.radius * s.radius;
    }
}
 

Here Shape is a union type , his recognizable reflected in where?

From the code, you can see that the incoming s variable may be Square, Rectangle or Circle. They have similarities and differences. So in the function, how do we write the corresponding code logic and ensure that the type is correct?

The answer is that we can judge the value of the .kind property . ts will reduce the type of s to a specific type according to the value of .kind. Reflected in the code, when s.kind === square, ts can determine that s must also have the s.size attribute. For Rectangle, Circle is the same, which reflects the recognizability of Shape as a joint type.

Correct but cumbersome writing

Let us use this feature to write code redux, we target is within reducer can distinguish different types of action according to .type property, and get the right type inference!

Suppose there are the following files

//actionTypes.ts

export const ADD_TODO = 'ADD_TODO';

export const REMOVE_TODO = 'REMOVE_TODO';

//actionCreators.ts

import * as Types from './actionTypes';

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
});

export const createRemoveTodo = (id: number) => ({
    type: Types.REMOVE_TODO,
    payload: id,
});

//reducer.ts

import * as Types from './actionTypes';

export default function reducer(state = {}, action: Actions) {
    switch (action.type) {
        case Types.ADD_TODO: {

        } break;
        case Types.REMOVE_TODO: {


            action.payload
        } break;
    }
}


 

How should we write the type so that we can get the correct action type inside the reducer function?

We first define the Action type according to the logic of the actionCreator functions

//actionCreators.ts

import * as Types from './actionTypes';

type AddTodoAction = {
    type: typeof Types.ADD_TODO;
    payload: string;
}

type RemoveTodoAction = {
    type: typeof Types.REMOVE_TODO,
    payload: number;
}
 

It should be noted here that we define the type of the .type attribute. Why do we not simply use string but typeof instead.

Only in this way can we ensure that the type of type of literal type , observe the above two pictures, ts .type estimated value of the property, while the .type property, we will be the key to distinguish between different types Action!

Then we combine these Action types

import * as Types from './actionTypes';

type AddTodoAction = {
    type: typeof Types.ADD_TODO;
    payload: string;
}

type RemoveTodoAction = {
    type: typeof Types.REMOVE_TODO,
    payload: number;
}

export type Actions = AddTodoAction | RemoveTodoAction;
 

At this point, we are not far from completing the goal, and all that is left is to mark the **Actions** type on the parameter of the reducer.

After marking, ts can automatically figure out what the value of action.type might be!

If we provide the wrong type value at the case, ts will report an error and prompt us for the allowed value at the case

More importantly, ts can already deduce the type of .payload based on the value of type!

In fact, this step is not over yet, our ultimate goal is to want the action object returned by the actionCreator function to be the same as the action value accepted by the reducer!

How to guarantee it? It's very simple, we need to mark the Action type we just defined on the corresponding actionCreator function

//actionCreator.ts

import * as Types from './actionTypes';

type AddTodoAction = {
    type: typeof Types.ADD_TODO;
    payload: string;
}

type RemoveTodoAction = {
    type: typeof Types.REMOVE_TODO,
    payload: number;
}

export type Actions = AddTodoAction | RemoveTodoAction;

export const createAddTodo = (text: string): AddTodoAction => ({
    type: Types.ADD_TODO,
    payload: text,
});

export const createRemoveTodo = (id: number): RemoveTodoAction => ({
    type: Types.REMOVE_TODO,
    payload: id,
});
 

So we have completed the connection between actionCreator and reducer! This is very important. Only in this way can we use the type of ts to ensure that the value returned by the actionCreator must be consistent with the action type inside the reducer.

Use ReturnType to simplify

Some experienced ts developers may suggest to use the ReturnType type function to optimize the above code, because actionCreator returns an action object, why don t we use ReturnType to directly get the return value type of the function as the corresponding Action type, so The association operation is automatically completed.

Such as:

import * as Types from './actionTypes';

type AddTodoAction = ReturnType<typeof createAddTodo>;

type RemoveTodoAction = ReturnType<typeof createRemoveTodo>;

export type Actions = AddTodoAction | RemoveTodoAction;

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
});

export const createRemoveTodo = (id: number) => ({
    type: Types.REMOVE_TODO,
    payload: id,
});
 

problem

Let's take a look at the type of action inside the reducer at this time

The inference fails, why is it so? Let's take a look at what the types of AddTodoAction and RemoveTodoAction are!

It can be seen that the .type attribute is inferred to be string. We know that the premise of recognizing the union type is the literal type. Here, the string type will not work.

So is there a way to tell ts to tell ts when doing type inference, I am a literal type, don't simplify me!

Why do this, because the use of the ReturnType type function allows us to automatically calculate the corresponding Action type based on the actual return value of the actionCreator, instead of manually modifying the Action type, and then modify the implementation logic of the actionCreator.

as const syntax

Let's take a look at the official introduction to as const

TypeScript 3.4 introduces a new construct for literal values called constassertions. Its syntax is a type assertion with constin place of the type name (eg 123 as const). When we construct new literal expressions with constassertions, we can signal to the language that

  • no literal types in that expression should be widened (eg no going from "hello"to string)
  • object literals get readonlyproperties
  • array literals become readonlytuples
//Type '"hello"'
let x = "hello" as const;

//Type 'readonly [10, 20]'
let y = [10, 20] as const;

//Type '{ readonly text: "hello" }'
let z = { text: "hello" } as const;
 

From the official introduction, the most useful to us is

no literal types in that expression should be widened (eg no going from "hello"to string)

The meaning of this sentence is that the literal type declared through as const will not be expanded into a " supertype " in type deduction. For example, for the literal type "hello", it will be expanded into type deduction. The string type, for the literal type 9999, will be expanded to the number type in the derivation.

Using this feature, we can write like this when defining actionType

export const ADD_TODO = 'ADD_TODO' as const;

export const REMOVE_TODO = 'REMOVE_TODO' as const;
 

At this time, let's take a look at ts's inference of the return value type of the actionCreator function

Let's look at the value after ReturnType

The same effect as we just used typeof! In order to reflect the benefits of using ReturnType, let's change the logic of actionCreator. We hang a .time attribute on the action object to indicate the time when the object was created.

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
    time: new Date(),
});
 

At this time, let s look at the type deduction inside the reducer

You can see that the type of action is still correct, and after we add the time attribute, the deduced type also adds the time attribute and its type!

In the same way, you can also write like this, just go to as const when defining actionCreator

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
    time: new Date(),
}) as const;

export const createRemoveTodo = (id: number) => ({
    type: Types.REMOVE_TODO,
    payload: id,
}) as const;
 

We can still get the type deduction we want, but this will make all the properties of the action object readonly, although I think it s okay to do so, you really shouldn t make changes to the action object.

So far, we have achieved our goal to ensure the correctness of the reducer's internal types, but we need to write a little more type-related code.

Type programming

In the process of actually writing the type, you can actually notice that our behavior of type annotation, using the ReturnType type function, is like in programming, we pass in a type and get a new type. So, can we use this type of programming ability to achieve automatic type inference?

As long as we write actionType and actionCreator, the reducer will automatically get the action type inside, and we don't need to specify it manually.

Let s take a look at what we have now. After we define the actionType through as const, we can get the type of the return value of the corresponding actionCreator function through ReturnType, and this type happens to be the type of Action we need, and finally we mark Go to the parameters of the reducer.

So what we should do now is how to get all the actionCreator function types in the actionCreators.ts file, and cyclically extract all their return value types, and combine them into a recognizable union type.

Get all actionCreator function types

Simple approach that requires compromise

Let's solve first, how to get all actionCreator function types? The easiest way is to define all actionCreator to an object, we can export this object.

export const actionCreators = {
    createAddTodo: (text: string) => ({
        type: Types.ADD_TODO,
        payload: text,
        time: new Date(),
    }) as const,
    createRemoveTodo: (id: number) => ({
        type: Types.REMOVE_TODO,
        payload: id,
    }) as const,
}
 

As you can see from the picture, we have already got an object type that contains the actionCreator function type.

Reluctant approach

Now we have obtained the type of actionCreator function we need, but we need to define all actionCreator functions to an object. If I don t, I have to reluctantly, and I have to achieve the same goal without changing my writing habits.

After thinking hard, I thought of ** import * as [name] from [module] ** syntax

As you can see, we still successfully get the desired type, and after using typeof, ts will carefully filter out the original module export type declaration, but there is actually a big problem here. If someone exports it in actionCreators.js What happens if other variables are removed?

problem

Suppose, someone exports a foo variable

import * as Types from './actionTypes';

type AddTodoAction = ReturnType<typeof createAddTodo>;

type RemoveTodoAction = ReturnType<typeof createRemoveTodo>;

export type Actions = AddTodoAction | RemoveTodoAction;

export const foo = 'foo';

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
    time: new Date(),
}) as const;

export const createRemoveTodo = (id: number) => ({
    type: Types.REMOVE_TODO,
    payload: id,
}) as const;

 

Manual filtering

You can see that RawActionCreators has an additional foo attribute type, although we can filter it manually

But this deviates from our original intention, we want to filter automatically!

We need to find a way to filter out types other than ActionCreator. Since you want to filter, then we must first determine what is a ActionCreator?

Here we define ActionCreator, a function that accepts indefinite parameters and returns an Action object, which is ActionCreator .

What is Action again? An object with a .type attribute is an Action object.

type Action = {
    type: string;
    [otherKey: string]: unknown;
};

type ActionCreator = (...args: unknown[]) => Action;
 

Automatic filtering

Then it's time for the magic to happen! **

type ExtractActionCreaotrKey<T> = {
    [K in keyof T]: T[K] extends ActionCreator ? K : never;
}[keyof T]
 

success

Through the ExtractActionCreaotrKey type function, we can correctly obtain the key names of all ActionCreator types on an object type!

You can see that foo is filtered out.

Now let s explain this code. The core of this code lies in another feature of ts, the conditional type.

T[K] extends ActionCreator? K: never

T is that we pass the object type, K refers to key names on behalf of the object type, T [K] indicates a key corresponding to the type, representing extends keyword is included relationship, this code means that if T [ K] If the type is ActionCreator or its subset, it returns K (that is, the key name corresponding to this type), otherwise it returns the never type.

Some people may be confused by the [keyof T] in the last line . Here we will see what the result would be without it.

You can see that the type of the foo attribute is never, and the types of other attributes become their key names, which is very consistent with the logic of the condition type we wrote, but our ultimate goal is to get the key name of the ActionCreator function type, and finally The [keyof T] is to play this step.

We got all the values of the object type, and their values corresponded to their key names. Naturally, we accomplished our goal. The never type disappeared in the process.

At this point, we are very close to our goal! Now we only need to get the key name and fill in RawActionCreators, then we can get only the type of ActionCreator!

You can see that the effect is the same as what we wrote manually, but all this is automatic, which means that once you define a new actionCreator function in actionCreators.ts, the ActionCreators here will automatically get its corresponding function type!

Final Results

Now we only need one step to mark the type of the reducer!

You can see that the type of ReducerAction is a combined Action type!

After annotation, the reducer successfully inferred the type of the action parameter.

What we got

Automatic type inference, in other words, we only need to define the actionType and write the actionCreator function, and then the relevant type information will be automatically added inside the reducer **

Encapsulation

Finally, we encapsulate the type-related code just now into a type function, which passes in an object type, and then combines all the Action types to return. Through this type function, we simply mark the type of the reducer, and the changes we need to pay are just to define the actionType using as const.

//utils.ts

type ActionCreator = (...args: any[]) => {
  type: string;
  [otherKey: string]: unknown;
};

type $ExtractActionCreatorKey<T> = {
  [K in keyof T]: T[K] extends ActionCreator ? K : never;
}[keyof T]

type $ExtractActions<T extends Record<string, ActionCreator>> = ReturnType<T[$ExtractActionCreatorKey<T>]>;


// 

import * as hasActionCreators from './hasActionCreators';

type Actions = $ExtractActions<typeof hasActionCreators>;

export default function reducer(state = {}, action: Actions) {
  //do something
}