37
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Redux typed actions でReducerを型安全に書く (TypeScriptのバージョン別)

Last updated at Posted at 2016-08-13

はじめに

Redux + TypeScriptな環境でAction typeに型を定義し、Reducerを型安全に書く方法をTypeScriptのバージョン 1.8 / 2.0 / 2.1 別にまとめてみた。

なお、試したサンプルコードは GitHub にあります。下記の通りTypeScriptのバージョン別にブランチで分けてあります。

また、TypeScript 2.0、2.1はまだリリースされていないので、8/12時点の物を使って確認している(2.0はnpm i typescript@betaで、2.1はnpm i typescript@nextでインストール)。

サンプルの内容

sampleApp.png

  • +ボタンをクリックするとカウンタを1つインクリメント
  • -ボタンをクリックするとカウンタを1つデクリメント
  • テキストフィールドを直接編集することで任意の値にカウンタを設定

というシンプルなアプリ。ReduxのActionは下記の3つを使う。プレーンなJavaScriptのObjectで定義する。

  • インクリメントのアクション
{
    type: 'INCREMENT'
}
  • デクリメントのアクション
{
    type: 'DECREMENT'
}
  • 任意の値にカウンタを設定するアクション
{
    type: 'SET_COUNT',
    payload: {
        count: 100
    }
}

以下、TypeScriptのバージョン別にAction、Reducerのコードを解説。

1.8の場合

1.8だと2.0から導入されるTagged union types が使えないので、User-defined type guard functionsを定義して頑張る方法がある。

Action

  • Action typeはstringとして扱えるようにtype ActionType<T> = string & _ActionType<T>のように定義したActionType<T>型で扱う。T型には各Actionの型を指定する。
  • isTypeのようにUser-defined type guard functionsとして型を絞るための共通関数を用意しておく。この関数で、先のActionType<T>T型、つまり各Actionの型に絞ることができるようにする。
Action
export interface _ActionType<T> { }
export type ActionType<T> = string & _ActionType<T>

export interface Action {
    type: String;
    payload?: any;
}

export function isType<T extends Action>(action: Action, type: _ActionType<T>): action is T {
    return action.type === type;
}

export const INCREMENT: ActionType<IncrementAction> = 'INCREMENT';
export const DECREMENT: ActionType<DecrementAction> = 'DECREMENT';
export const SET_COUNT: ActionType<SetCounterAction> = 'SET_COUNT';

export interface IncrementAction extends Action {
}
export function increment(): IncrementAction {
    return {
        type: INCREMENT
    };
}

export interface DecrementAction extends Action {
}
export function decrement(): DecrementAction {
    return {
        type: DECREMENT
    };
}

export interface SetCounterAction extends Action {
    payload: {
        count: number;
    }
}
export function setCount(num: number): SetCounterAction {
    return {
        type: SET_COUNT,
        payload: {
            count: num
        }
    };
}

Reducer

  • Actionで定義したisType関数を使ってaction変数の型を絞ることができる。
  • Reduxのサンプルで良くあるようなswitch caseを使った形にはできない。
Reducer
export const appStateReducer = (state: AppState = init(), action: Actions.Action) => {
    if (Actions.isType(action, Actions.INCREMENT)) {
        return Object.assign({}, state, {
            count: state.count + 1
        });
    }

    if (Actions.isType(action, Actions.DECREMENT)) {
        return Object.assign({}, state, {
            count: state.count - 1
        });
    }

    if (Actions.isType(action, Actions.SET_COUNT)) {
        return Object.assign({}, state, {
            count: action.payload.count
        });
    }

    return state;
};

User-defined type guard functionsにより型が絞られるので、下記のようにちゃんとaction変数の型を認識してくれている。存在しないプロパティにアクセスすればもちろんコンパイルエラーとなる。

ts180.png

なお、このやり方はReduxのIssue: Support for typed actions (Typescript) #992 で紹介されていたものです。他にもClassを使った方法もここで紹介されているが、ActionごとにClassを定義するのはやり過ぎな気がするのでこのやり方がシンプルかなと思っている。

2.0の場合

2.0だとTagged union typesという機能が追加されており、String Literalで型定義したtypeでActionの型を判別し絞り込むことができるようになる。

一部微妙な書き方

書き方はシンプルで良いのだが型安全でないところがある書き方をまず紹介。

Action

  • 1.8の頃と違い、素直にString Literaltypeを定義するだけと非常にシンプル。
Action
import { Action } from 'redux';

export type Actions = IncrementAction | DecrementAction | SetCounterAction;

export interface IncrementAction extends Action {
    type: 'INCREMENT';
}
export function increment(): IncrementAction {
    return {
        type: 'INCREMENT'
    };
}

export interface DecrementAction extends Action {
    type: 'DECREMENT';
}
export function decrement(): DecrementAction {
    return {
        type: 'DECREMENT'
    };
}

export interface SetCounterAction extends Action {
    type: 'SET_COUNT';
    payload: {
        count: number;
    }
}
export function setCount(num: number): SetCounterAction {
    return {
        type: 'SET_COUNT',
        payload: {
            count: num
        }
    };
}

Reducer

  • 素直にString Literalswitch caseを書く。こちらもシンプル。
Reducer
export const appStateReducer = (state: AppState = init(), action: Actions.Actions) => {
    switch (action.type) {

        case 'INCREMENT':
            return Object.assign({}, state, {
                count: state.count + 1
            });

        case 'DECREMENT':
            return Object.assign({}, state, {
                count: state.count + -1
            });

        case 'SET_COUNT':
            return Object.assign({}, state, {
                count: action.payload.count
            });
    }

    return state;
};

これで型の絞り込みは行われる。例えば以下のように、case 'SET_COUNT': 内ではちゃんとaction変数の型が絞られていることが分かる。

ts200-narrow.png

しかし残念な点もある。以下のように、case 'DECREMENT': -> case 'AAAAA': のように存在しないAction typeを書いたとしても、コンパイルエラーとして検知してくれず型安全ではない。

ts200.png

コンパイルエラーを出させるためにちょっと工夫したやり方

アイデアとしては、String Literalを直接書かずに変数でcaseを書けるようにすれば、タイプミスしてもコンパイルエラーとして検知される。

Action

  • 直接String Literaltypeを定義はせず、const INCREMENT: 'INCREMENT' = 'INCREMENT'; のようにString Literalの定数を宣言しておく。
  • その定数を type: typeof INCREMENT; のように使い、typeの型をString Literalとして定義する。
Action
import { Action } from 'redux'

export type Actions = IncrementAction | DecrementAction | SetCounterAction;

export const INCREMENT: 'INCREMENT' = 'INCREMENT';
export const DECREMENT: 'DECREMENT' = 'DECREMENT';
export const SET_COUNT: 'SET_COUNT' = 'SET_COUNT';

export interface IncrementAction extends Action {
    type: typeof INCREMENT;
}
export function increment(): IncrementAction {
    return {
        type: INCREMENT
    };
}

export interface DecrementAction extends Action {
    type: typeof DECREMENT;
}
export function decrement(): DecrementAction {
    return {
        type: DECREMENT
    };
}

export interface SetCounterAction extends Action {
    type: typeof SET_COUNT;
    payload: {
        count: number;
    }
}
export function setCount(num: number): SetCounterAction {
    return {
        type: SET_COUNT,
        payload: {
            count: num
        }
    };
}

Reducer

  • Actionで定義した定数を使ってcaseを指定する。
  • 直接String Literalを入力しないので、タイプミスした場合もこれならコンパイルエラーとして検知できる。
  • もちろん、型の絞り込みも行われる。
Reducer
export const appStateReducer = (state: AppState = init(), action: Actions.Actions) => {
    switch (action.type) {

        case Actions.INCREMENT:
            return Object.assign({}, state, {
                count: state.count + 1
            });

        case Actions.DECREMENT:
            return Object.assign({}, state, {
                count: state.count + -1
            });

        case Actions.SET_COUNT:
            return Object.assign({}, state, {
                count: action.payload.count
            });
    }

    return state;
};

2.1の場合

2.1だとTagged union typesがさらに改善されているようで、2.0の時のような微妙な工夫も必要なくなる。つまり、2.0で紹介した一部駄目なやり方でも型安全となる。全く同じコードだが再掲する。

Action

Action
import { Action } from 'redux';

export type Actions = IncrementAction | DecrementAction | SetCounterAction;

export interface IncrementAction extends Action {
    type: 'INCREMENT';
}
export function increment(): IncrementAction {
    return {
        type: 'INCREMENT'
    };
}

export interface DecrementAction extends Action {
    type: 'DECREMENT';
}
export function decrement(): DecrementAction {
    return {
        type: 'DECREMENT'
    };
}

export interface SetCounterAction extends Action {
    type: 'SET_COUNT';
    payload: {
        count: number;
    }
}
export function setCount(num: number): SetCounterAction {
    return {
        type: 'SET_COUNT',
        payload: {
            count: num
        }
    };
}

Reducer

Reducer
export const appStateReducer = (state: AppState = init(), action: Actions.Actions) => {
    switch (action.type) {

        case 'INCREMENT':
            return Object.assign({}, state, {
                count: state.count + 1
            });

        case 'DECREMENT':
            return Object.assign({}, state, {
                count: state.count + -1
            });

        case 'SET_COUNT':
            return Object.assign({}, state, {
                count: action.payload.count
            });
    }

    return state;
};

2.0と異なり、action.typeに存在しないString Literalswitch caseに書くとちゃんとコンパイルエラーとして検出される。便利。

ts210.png

まとめ

  • TypeScriptを使うとReduxのReducerを型安全に書けるようになるのでいいよ。1.8でもちょっと面倒だができなくはない。
  • TypeScript 2 のTagged union typesでは簡単に書けるようになるよ。
  • ただし2.0だと不十分で、型の絞り込みはしてくれるがString LiteralのAction typeをタイプミスしてしまうとコンパイルで検知してくれず。検知させるには別途変数を定義しておく必要がありちょっと面倒。
  • TypeScript 2.1ならここは改善されていて、String Literalのままでいけて良い感じ!

というわけではやく2.1がリリースされて欲しい...

37
25
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
37
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?