初めまして、11月にGAにJoinしたBlockchain Strategy Centerの中村です。 最近、IMAXレーザー、応援上映と爆音上映で『ボヘミアン・ラプソディ』を3回観ました。ラミ・マレック扮するフレディ・マーキュリーの型にハマらないステージパフォーマンスに圧倒されましたが、今日は型の話をします。(ブロックチェーンでなくてすみません)
TL;DR
- discriminaited unionでreducerがtypesafeに書ける
- mapped types / Genericsの組み合わせでreduxのactionCreatorからunion型を推論可能
はじめに
GA technologiesでは、日々既存プロダクトのエンハンス開発や新規サービスの企画・開発が行われています。 その内の一つでもReactJSが利用されていますが、この記事では、React/Reduxのケーススタディを通じてTypeScriptの応用的な使い方を解説します。 故にReduxデータフローの概念についての基礎的な知識がある事を前提とはしますが、Union TypesやGenericsなどTypeScriptの発展的な使い方知りたい方を主な対象とします。
本題
TypeScriptとReact/Reduxを組み合わせる際、コンパイラでのエラー検出など静的型付けの恩恵を最大限享受したいものですが、素朴な実装では往々にして以下の問題が起こります。
- 課題 1. reducerでactionの型推論がされない
- 課題 2. actionCreator(Actionを生成する関数)と別に、Actionの型定義をリテラルで行う必要がある
以下では 課題 1. と 課題 2. それぞれの解決方法を解説します
課題1. reducerでactionの型推論がされない
結論から言うと、discriminated union の導入により解決が可能です。
そもそも 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 unionとは?
日本語では「判別共用体」と訳す事が多いようですが、意味不明なので平易に読み下すと、「弁別(一意に特定)されたunion型」のことです。それでも意味不明なので、下記のコードを参照ください。 redux 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化
さて、サンプルでよくある、下記のようなUIを持つTodoListの例で考えます。
// index.d.ts interface Task { id: number content: string isDone: boolean }
// action.ts 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でこれを利用すると下記のように書く事が可能です。
// reducer.ts 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の型定義をリテラルで行う必要がある
され、もうこれで終わりなのでは?と思いきや、上述のコードには問題が残っています。
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をいくつかに分けてみます。
- step 0. actionCreatorを定義 # 今ココ!
- step 1 actionCreatorの型を定義
- step 2. actionCreatorの戻り値の型を推論
- step 3. 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です。 正規表現のキャプチャの型版と考えるとわかりやすいです。 読み解くためのポイントは, あらゆる関数の型は
type AnyFunction = (...args: any[]) => 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に書けるのは有り難いですね!
GA technologiesでは一緒に働く開発者を募集しています。(Blockchain Strategy Centerでも絶賛エンジニア募集中です!) ご興味を持たれた方はぜひ下記からご応募ください!