はじめに
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
でインストール)。
サンプルの内容
-
+
ボタンをクリックするとカウンタを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の型に絞ることができるようにする。
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
を使った形にはできない。
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
変数の型を認識してくれている。存在しないプロパティにアクセスすればもちろんコンパイルエラーとなる。
なお、このやり方は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 Literalで
type
を定義するだけと非常にシンプル。
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 Literalで
switch case
を書く。こちらもシンプル。
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
変数の型が絞られていることが分かる。
しかし残念な点もある。以下のように、case 'DECREMENT':
-> case 'AAAAA':
のように存在しないAction typeを書いたとしても、コンパイルエラーとして検知してくれず型安全ではない。
コンパイルエラーを出させるためにちょっと工夫したやり方
アイデアとしては、String Literalを直接書かずに変数でcase
を書けるようにすれば、タイプミスしてもコンパイルエラーとして検知される。
Action
- 直接String Literalで
type
を定義はせず、const INCREMENT: 'INCREMENT' = 'INCREMENT';
のようにString Literalの定数を宣言しておく。 - その定数を
type: typeof INCREMENT;
のように使い、type
の型をString Literalとして定義する。
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を入力しないので、タイプミスした場合もこれならコンパイルエラーとして検知できる。
- もちろん、型の絞り込みも行われる。
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
- 素直にString Literalで
type
を定義する。
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 Literalで
switch case
を書く。
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 Literalをswitch case
に書くとちゃんとコンパイルエラーとして検出される。便利。
まとめ
- TypeScriptを使うとReduxのReducerを型安全に書けるようになるのでいいよ。1.8でもちょっと面倒だができなくはない。
- TypeScript 2 のTagged union typesでは簡単に書けるようになるよ。
- ただし2.0だと不十分で、型の絞り込みはしてくれるがString LiteralのAction typeをタイプミスしてしまうとコンパイルで検知してくれず。検知させるには別途変数を定義しておく必要がありちょっと面倒。
- TypeScript 2.1ならここは改善されていて、String Literalのままでいけて良い感じ!
というわけではやく2.1がリリースされて欲しい...