序文
この記事では、ReduxのAction型を、(React/Redux以外の3rdPartyLibraryを使わずに!)actionCreatorから動的に定義する方法を解説します.
尚,本記事で解説したサンプルを含む TypeScript + React/Redux AppのBoilerplateは下記で公開しています
tomnack93/react-redux-boilerplate
Reduxデータフローの概念についての基礎的な知識がある事を前提とはしますが, TypeScriptの高度型についてReduxを題材に解説するという体裁をとるため,
- 表題の課題に直面している方
のみならず、
- Union TypesやGenericsなどTypeScriptの発展的な使い方知りたい方
も読者の対象とします.
本題
TypeScriptとReact/Reduxを組み合わせる際,コンパイラでのエラー検出など静的型付けの恩恵を最大限享受したいものですが,素朴な実装では往々にして以下の問題が起こります.
- reducerでactionの型推論がされない
- actionCreator(Actionを生成する関数)とActionの型定義をリテラルで行う必要がある
以下では 1. と 2. それぞれの課題について自前実装で解決する方法を解説します
課題1. reducerでactionの型推論がされない
結論から言うと、discriminated unions の導入により解決が可能です
そもそも Union Type とは?
TypeScriptでは、複数の型を許容するUnion Typeという型が利用可能です.
例として,string型だがnullになり得るかもしれない型は下記のように表します
type OptionalString = string | null
const stringArray: OptionalString[] = ['a', null, 'b', 'c']
型システムに慣れていない場合、ここで若干の発想の飛躍が必要ですが string | null
はこれで一つの型を表します.
swiftが書ける方はお察しの通りですが、これはまさしくOptional型の表現です.
ちなみに、TypeScriptではOptionalに相当する概念をNullableと呼んでいます.
確かに後者の方がわかりやすい!
discriminated unionsとは?
日本語では「判別共用体」と訳す事が多いようですが、意味不明なので平易に読み下すと、「弁別(一意に特定)されたUnionType」のことです。それでも意味不明なので、下記のコードを参照ください。
actionの型は複雑なので、シンプルな例で説明します
interface Square {
kind: "square"; // discriminant,
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
type Shape = Square | Rectangle | Circle; // union types
さてここでShapeというUnionTypeを定義しましたが, 勘の良い方はSquare, Rectangle, Circle全てに kind というpropertyがあることに気づいたと思います。
これは discriminant と呼ばれ, Shapeが具体的にどの型であるか、実行時に弁別するための識別子と思えばよいです。
型の判別を具体的行っているのが以下のswitch文です。
s.kindという値でswitchさせています
function area(s: Shape) {
switch (s.kind) {
// ここでは s は Shape型
case "square":
// s の型がSquareに確定する
return s.size * s.size;
case "rectangle":
// s の型がRectangleに確定する
return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
default:
// sの型がexhaustiveに識別されているかの確認, この後説明
return assertNever(s);
}
}
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
面積を求めるarea functionの引数sは、Shape型ですが,
switch文により弁別(discriminate)された後は型が確定します.
シュタインズ・ゲート風に言うと,平行世界に存在する複数の世界線がα世界線に確定した状態です.
never型とは?
先程のswitch文のdefaultにneverという型がある事に気がついたと思われますが、これは単体と理解し難いのでdiscriminated unionと関連させて覚えてください.
説明のために先程のswitch文をif文でrewriteします.
elseの中の s の型をチェックすると, Rectangle | Circle
というunion型になっています.
if文の条件で Square 型の判定を行っているため、Square型の可能性が消えているわけです.
never型は、この可能性が取り尽くされた、本来ありえない状態の値の型です.
先程のassertNeverは実行される事はなく, switch文が網羅的になっていないことをコンパイラに検出させるための関数です.
シュタインズ・ゲート風に言うと本来辿り着けるはずの無かったシュタインズ・ゲート世界線と言えます.
function area(s: Shape) {
// ここでは s は Shape型
if (s.kind === "square") {
// s の型がSquareに確定する
} else {
// s の型が Rectangle | Circle になる
}
}
Redux actionをDiscriminated Union化
さて、サンプルでよくあるTodoListの例で考えます.
interface Task {
id: number
content: string
isDone: boolean
}
export enum ACTIONS {
ADD_TASK = "ADD_TASK",
TOGGLE_TASK = "TOGGLE_TASK"
}
// TodoListにTaskを追加
function addTask(task: Task) {
return {
type: ACTIONS.ADD_TASK as ACTIONS.ADD_TASK,
task: task
}
}
// TodoListの状態を切り替えるaction
function toggleTask(id: number) {
return {
type: ACTIONS.TOGGLE_TASK as ACTIONS.TOGGLE_TASK,
id: id
}
}
// この型を動的に定義する方法は後述
type UnionedAction = {
type: ACTIONS.ADD_TASK;
task: Task;
} | {
type: ACTIONS.TOGGLE_TASK;
id: number;
}
このReduxはaddTaskとtoggleTaskという2種類のactionを持ち,
これらのActionのunion typeをリテラルで書くとUnionedActionのようになります.
さて実際にreducerでこれを利用すると下記のように書く事が可能です
import { ACTIONS, UnionedAction } from '../actions';
export interface TodoState {
tasks: Task[]
}
const initialState: TodoState = {
tasks: []
}
export function todoReducer(state = initialState, action: UnionedAction) {
const { tasks } = state
switch (action.type) {
case ACTIONS.ADD_TASK:
return Object.assign({}, state, {
tasks: [
...tasks,
action.task
]
})
case ACTIONS.TOGGLE_TASK:
const i = tasks.findIndex((v, _) => { return v.id === action.id});
tasks[i].isDone = !tasks[i].isDone;
return Object.assign({}, state, {
tasks: tasks
})
default:
((_: never): void => { return })(action);
return state
}
}
課題2. actionCreator(Actionを生成する関数)とActionの型定義をリテラルで行う必要がある
され、もうこれで終わりなのでは?と思いきや、上述のコードには問題が残っています.
UnionedAction
がリテラルで定義されてしまっていることです.
この記事の後半は、 actionCreatorである addTask, toggleTask
から, Genericsを利用して UnionedAction
型を推論する方法を解説します.
スタートとゴールを再確認すると
// Start
export enum ACTIONS {
ADD_TASK = "ADD_TASK",
TOGGLE_TASK = "TOGGLE_TASK"
}
function addTask(task: Task) {
return {
type: ACTIONS.ADD_TASK as ACTIONS.ADD_TASK,
task: task
}
}
function toggleTask(id: number) {
return {
type: ACTIONS.TOGGLE_TASK as ACTIONS.TOGGLE_TASK,
id: id
}
}
// Goal
type UnionedAction = {
type: ACTIONS.ADD_TASK;
task: Task;
} | {
type: ACTIONS.TOGGLE_TASK;
id: number;
}
actionCreator function から UnionedAction type を定義するまでのstepをいくつかに分けてみます
0
. actionCreatorを定義 # 今ココ
- actionCreatorの型を生成
- actionCreatorの戻り値の型を推論
- actionCreatorの戻り値をUnionする
step1. actionCreatorの型を定義
このstepは比較的シンプルで、 typeof
演算子を適用する事で関数から関数の型を得ることができます
まずは actionCreatorをオブジェクトにまとめ, typeof演算子を適用すると下記のような型が得られます
export const creators = { addTask, toggleTask }
type T1 = typeof creators
type T1 = {
addTask: (task: Task) => {
type: ACTIONS.ADD_TASK;
task: Task;
};
toggleTask: (id: number) => {
type: ACTIONS.TOGGLE_TASK;
id: number;
};
}
step2. actionCreatorの戻り値の型を生成
これも比較的簡単で、TypeScriptには ReturnType<T>
という, 関数型から戻り値の型を定義するbuilt-inの型があります.
type ReturnType<T> = T extends ((...args: any[]) => infer R) ? R : never;
infer とは?
型をキャプチャするためのsyntaxです.
そのまま、正規表現のキャプチャを型に適用したものと考えてください.
読み解くためのポイントは, あらゆる関数の型は (...args: any[]) => ret:any
と書けます。
infer R は, 戻り値のRをキャプチャしており,
型引数Tの型が関数型に代入できる場合, キャプチャした戻り値のRを生成
という事を行っています.
ReturnTypes
さて、ここで 複数のactionCreators型の戻値をまとめて返す ReturnTypes
という型を定義します. Mapped types
という、型のにMap操作を適用して新たな型を定義する仕組みを利用します.
Genericsの型引数は, 大文字一文字を利用するのが通例かと思いますが、説明のためにわかりやすい命名をしました. FunctionMap[Key]
が, 個々のactionCreatorの型を指すと考えてください.
type AnyFunction = (...args: any[]) => any
type ReturnTypes<FunctionMap> = {
[Key in keyof FunctionMap]: FunctionMap[Key] extends AnyFunction ?
ReturnType<FunctionMap[Key]> :
never
}
type ReturnsMap = ReturnTypes<typeof creators>
以下の ReturnsMap
の型はリテラルで書くと以下と同義です
type ReturnsMap = {
addTask: {
type: ACTIONS.ADD_TASK;
task: Task;
};
toggleTask: {
type: ACTIONS.TOGGLE_TASK;
id: number;
};
}
step3. actionCreatorの戻り値をUnionする
最後に, ReturnsMap型からActionのUnionTypeを定義するために,
MapToUnionというGenericsを定義します
type MapToUnion<T> = T extends {[A in keyof T]: infer U} ? U :never
MapToUnionの挙動を理解する前に, 例のごとく簡単な例で考えて見ます.
type MapToUnion<T> = T extends { a: infer U, b: infer U } ? U : never;
type T1 = MapToUnion<{ a: string, b: number }>; // string | number
先の例では, MapのPropertyは未知でしたが、下記ではMapToUnionの型引数Tは必ずproperty a
, b
を持つとします.
すると, infer U では string型とnumber型が推論され, その結果T1の型としては, string | numberのどちらでもあり得る string | number 型が推論されます.
最終型
まとめると, actions.tsを下記のように書けます.
説明のためにステップごとにGenericsを書き下しましたが,
もうすこしまとめてしまってもよいかと思います.
export const creators = { addTask, toggleTask }
type AnyFunction = (...args: any[]) => any
type ReturnTypes<FS> = {
[F in keyof FS]: FS[F] extends AnyFunction ?
ReturnType<FS[F]> :
never
}
type ReturnsMap = ReturnTypes<typeof creators>
type MapToUnion<T> = T extends {[K in keyof T]: infer U} ? U : never
export type UnionedAction = MapToUnion<ReturnsMap>
あとがき
後半で力尽きた感はありますが、要素要素に噛み砕いたので, actionCreatorから型を動的に定義する仕組みがわかったのではないかと思います.
高度な型でも any に頼らずtypesafeに書けるのは有り難いですね!