React Reduxを型で縛ってみよう
井手 優太
はじめに
こんにちは!Air メイト開発エンジニアの@sadnessOjisanです。
Air メイトでは、サービス検討から 2 年ほど、PMF (Product Market Fit) を達成するために、次々と新機能を開発していました。 PMF が少しずつ見えて来て、開発も少しずつ落ち着いてきたため、2 ヶ月ほど前から機能開発だけでなく、コードの品質を見直すべくテストや型を書きはじめました。
ただ私は、Web デザインの延長で JavaScript を書き始めたエンジニアで、型にあまり馴染みがない開発者でした。 そこで型を書くために、「JavaScript 型 書き方」で検索しましたが、当時の私が理解できるような丁寧な記事や、実務で型をつけるためのプラクティス集が見当たらなくて困っていた。たまたま、バックエンドチームにとても型について詳しい方がいらっしゃったので、その方に色々と教えてもらいながら、React, Redux で書いたアプリケーションに対して型を書いてみました。
最近はなんとか型を書けるようになってきたので、これから型を書きたい方向けに、JavaScript や React アプリケーションを型で縛るための効果的な方法を伝えたく、この記事を書きました。 きっと、私のように型に馴染みがなかったJSエンジニアは多くいらっしゃると思います。この記事は、そのような方に届けたくて書いた記事です。
「型は怖くなかったよっ!!とても心強い味方だったよ!!!」
記事の読み方
この記事の想定する読者
型か書いた経験がなかった過去の自分に読み聞かせたいという前提でこの記事を書いています。 おそらく次のケースに当てはまる方がまさしく、対象読者に当てはまると思います。
- 型をつけたことがないが、これから触る必要がある JS エンジニア
- 現在、試行錯誤しながら型を付けている JS エンジニア
普段から型に慣れている人にとっては、最後の章以外は面白くないかもしれません。
目次/この記事に書かれていること
- なぜ型をつけるのか
- 型の種類にはどのようなものがあり、利用することでどう嬉しくなるのか
- Primitive 型
- Literal 型
- Array 型
- ReadOnlyArray 型
- Any 型、Mixed 型、Empty 型
- Maybe 型
- Function 型
- Object 型
- Generic 型
- Union 型
- Intersection 型
- Typeof 型
- $ReadOnly
- $Keys
- $Exact
- 実務で型をつけるに当たって知っておきたいこと
- 型推論と型注釈
- 型は使い回す
- ライブラリに型をつける
- 型情報を取得する
- 型情報がないライブラリに型をつける
- React Redux で開発されたアプリケーションを型で縛るプラクティス
- props, state, store は ReadOnly で縛る
- Props の型は種類ごとに作り、マージする
- nullable と推論されたが、開発者が null にならないことを知っている場合、型検査のエラーを無視する
- redux の action 型を、action creator の型の Union 型で作る
- combineReducer する際に store の型も一緒に作る
- container の props に含まれる action creator の型は、typeof を使って作る
- redux-saga と併用する際は、yield された値に型注釈をつける
- まとめ
この記事では静的型チェッカとしてFlow を例として説明します。 私のチームでは、部分的に型を導入していける・既存のコードの .babelrc に設定を足せばすぐ動かせるという理由で、 Flow を選択しました。 そのため、この記事では Flow を題材に解説しますが、紹介する内容はTypeScriptでも同じことができます。そのため、TypeScript ユーザーも安心して読んでいただければと思います。
なぜ型をつけるのか
型がもたらす嬉しさは、プログラムが(ある意味で)正しく動くことを保証してくれるところにあります。
プログラミング言語には、動的型付き言語と静的型付き言語と呼ばれるものがあります。
JavaScript は動的型付き言語に属し、変数や関数の型を実行時に検査します。 そのため、本来その型にないメソッドやプロパティを呼び出すコードを書いていると、実行時に落ちることもあります。
一方で静的型付き言語は、コンパイルなど実行前に変数や関数の型を検査するプログラミング言語です。 たとえば、Go や Java が静的型付き言語に当てはまります。 これらは本来その型にないメソッドやプロパティを呼び出すコードを書いていると、実行前の型検査によってエラーが発生し、実行時に落ちるということを防ぐことができます。
静的型付き言語は、動的型付き言語に比べて、実行前に型安全性が保証されることや、開発中も変数の中などがわかることによって開発効率が向上するという利点があります。
このような静的型付き言語の恩恵を受けるためのツール・言語が JavaScript にあります。それが Flow や TypeScript です。 動的型付き言語である JavaScript に型の定義を与え、それをこれらのツールを用いてチェックすることで、静的型付き言語がもつ恩恵を受けることができます。
1
2
3
4
5
/* @flow */
function foo(x: number): string {
return x; // Error Cannot return `x` because number [1] is incompatible with string [2].
}
(string の返り値が返る関数であることを宣言しているのに、number 型の値を返しているため、型チェックによってエラーが出ている例。このように型をチェックすることで誤ったプログラムを検知してくれる。)
また型を宣言していると、エディターの補完も効きやすくなるという利点もあり、開発効率も上がります。
型の種類にはどのようなものがあり、利用することでどう嬉しくなるのか
Flow Docs/Types に書かれている内容を、使うとどういう嬉しさがあるのかということを実例を交えながら説明いたします。全部の型を説明するのは、あまりにも膨大な上、使う機会が非常に少ないものも含まれているため、仕事でよく使っている型のみに絞って、説明します。
ここに登場する型は Flow Try で試すことができます。
Primitive 型
JavaScript はプリミティブデータ型が定義されています。 Flow がサポートしているプリミティブ型は次の 5 つです。
- Boolean: ex) true, false
- Number: ex) 1, -1, 1.0
- String: ex) ‘hoge’
- Null
- void (JavaScript でいう undefined)
Flow では、これらは小文字で記述されることに注意しましょう。
1
2
3
4
/* @flow */
const val: number = 1; // No Errors
const val2: Number = 2; // Error Cannot assign `2` to `val2` because number [1] is incompatible with `Number` [2].
(MDNより引用) (※ ECMAScript が定義するプリミティブ型には、Symbol 型もありますが、これは Flow ではサポートされていません。)
Literal 型
literal(値)そのものを型とします。つまり、hoge: 3
のような定義が可能です。
1
2
3
4
5
6
7
8
9
/* @flow */
function foo(): 1 {
return 1; // No errors!
}
function bar(): 2 {
return 1; // Error!
}
対象が、複数のリテラルを取りうる場合は、後述する Union 型を使って、hoge: 1 | 2 | 3
と定義することも可能です。
これは、enum の定義にもなるという嬉しさがあります。
1
2
3
4
5
/* @flow */
function foo(): 1 | 2 | 3 {
return 1; // No errors!
}
Array 型
Array<型名>とすることで、ある型をもった要素の配列を定義できます。
1
let arr: Array<number> = [1, 2, 3];
また、次のような記法もできます。
1
let arr: number[] = [1, 2, 3];
上の例では、Number 型以外の値を配列に入れると、Error になります。
1
2
3
let arr: number[] = [1, 2, 3];
arr[0] = 4; // No Errors!
arr[1] = "1"; // Error ^ Cannot assign `'1'` to `arr[1]` because string [1] is incompatible with number [2].
ReadOnlyArray 型
配列の型には、ReadOnlyArray と呼ばれる型があります。 これは配列の破壊的変更を禁止してくれる型です。
1
2
3
4
5
6
const normalArray: Array<number> = [1, 2, 3];
readonlyArray[0] = 4; // No errors!
const readonlyArray: $ReadOnlyArray<number> = [1, 2, 3];
readonlyArray[0] = 4;
// Error ^ Cannot assign `4` to `readonlyArray[0]` because the index must be statically known to write a tuple element.
React の State や Redux の Store は書き換えを禁止しているため、それらに登場する Array を ReadOnlyArray で縛っておくと、より安全性が増します。
Any 型、Mixed 型、Empty 型
any は文字通り、どんなものにも当てはまる型です。 たとえば、どんな値がどんな構造で返ってくるのかがわからない・変わりうるような関数に型をつけないといけないような場合に使えます。 (もしかすると、前任者がいい加減に開発したまま、失踪した場合などコードを引き継ぐと、そういう関数に出会うかもしれません。)
みなさんも型定義が合わない時に、切り札として使ったこともあるのではないでしょうか。
しかし any はあまり多用すべきでありません。どうしても使いたいならば Mixed Types を使えないか検討してみましょう。 Mixed 型は、すべての型のスーパータイプであり、任意の型を受け取れる型です。 ただし mixed 型の値を利用する際は、Type Refinementsを行って、その変数が何の型であるかを検証しないと、エラーを吐きます。 mixed 型の変数には何でも入れられますが一度入れると元が何の型だったのかわからなくなるため、Type Refinementsが要求されます。 そのため、any 型と違って、mixed 型の値が他の処理で使われる際は、mixed 型の値が何の型かを知った上で利用できます。
any 型の値と他の型の値を用いてなんらかの計算をしたとき、その結果も any 型になってしまい、せっかく型で縛っても、その縛りがゆるまってしまいます。 局所的に仕方なく使う場合は、その箇所以外に any 型が漏れないように注意しないといけません。 Flow の公式ドキュメントでも、Avoid leaking any という節があり、
1
2
3
4
5
6
// @flow
function fn(obj: any) {
let foo = obj.foo;
let bar = foo * 2;
return bar;
}
とあるのを、
1
2
3
4
5
6
// @flow
function fn(obj: any) {
let foo: number = obj.foo;
let bar = foo * 2;
return bar;
}
とするように記述されています。
ちなみに Mixed Types を使うとこの any の漏れ出しは防げます。(漏れる前に Error を吐くので、型チェックが通らないため)
1
2
3
4
5
6
7
8
9
10
11
// @flow
function stringify(value: mixed) {
return "" + value; // Error!
}
/* @flow */
function stringify(value: mixed) {
if (typeof value === "string") {
return "" + value; // No errors!
}
}
Any 型や Mixed 型はすべての型のスーパータイプですが、反対にすべての型のサブタイプも存在します。 Empty 型がまさしくそうです。Empty 型は Flow が提供する型すべてに含まれている型です。 この Empty 型はどんな値も受け取ることができません。 一見、使い道がないのですが、例外を投げる関数の返り値の型に設定したり、reducer の case 文の抜け漏れを検知するために使います。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// @flow
type State = { +value: boolean };
type FooAction = { type: "FOO", foo: boolean };
type BarAction = { type: "BAR", bar: boolean };
type Action = FooAction | BarAction;
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FOO":
return { ...state, value: action.foo };
default:
(action: empty); // Error! Cannot cast `action` to empty because `BarAction` [1] is incompatible with empty [2].
return state;
}
}
上の例では、BAR の case 文を追加すると型検査が通ります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* @flow */
type State = { +value: boolean };
type FooAction = { type: "FOO", foo: boolean };
type BarAction = { type: "BAR", bar: boolean };
type Action = FooAction | BarAction;
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FOO":
return { ...state, value: action.foo };
case "BAR":
return { ...state, value: action.bar };
default:
(action: empty);
return state;
}
}
(action: empty)
に何か値が入るとエラーを吐くことを逆手にとって、defaul 節に降りてきた case の抜け漏れを実行前に検知できます。
any, mixed, empty を整理すると次の通りになります。
型 | 説明 |
---|---|
any | すべての型のスーパータイプかつ、すべての型のサブタイプ |
mixed | すべての型のスーパータイプ(すべての型を含む型)。どんな値も受け取ることができる。 |
empty | すべての型のサブタイプ(すべての型に含まれている型)。どんな値も受け取ることができない。 |
ちなみに 2019 年 1 月現在、なぜか Empty 型のドキュメントが公式にありません。Issueも Open のままですので、実は公式 Doc を読むだけだと、この機能に気づくことは難しいです。
Maybe 型
Nullable な型は Maybe 型と呼ばれています。これは型名に?
をつけることで定義できます。
たとえば、
1
let a: ?number = 0;
と定義すれば、Number 型、もしくは null もしくは undefined と扱われます。
実務では、API から返ってくるデータを保持するプロパティに指定しています。 API から取ってきたデータを保存するプロパティは、データが返ってくるまでは null です。 maybe 型を使うことで、null も取りうるということを表現できます。
1
2
3
4
type ApiReuturn = {
isLoading: boolean,
data: ?Array<Object>
};
Function 型
1
2
3
function hoge(fuga: number, fuga: boolean): string {
return "piyo";
}
とある関数に対して、
1
2
/* @flow */
type Func = { hoge: (fuga: number, piyo: boolean) => string };
と型をつけることができます。Function 型は React の Props 型の定義でよく使います。
Object 型
オブジェクトのプロパティとその型を列挙することで、独自の型を定義できます。
たとえば、
1
2
3
4
type Piyo = {
hoge: number,
fuga: string
};
とすることで、Piyo 型を定義できます。
このtype
は型エイリアスと呼ばれ、複雑な型に名前を付けることができます。
Object Type は export
することができ、それを別ファイルでimport
して使い回すこともできるため、重宝されます。
React 開発では Props や State を Object 型として定義して使います。
1
2
3
4
5
type Props = {};
type State = {};
class Sample extends React.Component<Props, State> {}
Generic 型
総称型やジェネリクスと呼ばれているものです。 これを用いると、型を変数かのように扱えます。
1
2
3
4
5
6
7
8
9
10
11
12
13
/* @flow */
type Response<T> = {
statusCode: number,
data: T,
respondedAt: string
};
type User = { name: string };
type Post = { content: string };
type UserAPIResponse = Response<User>;
type PostAPIResponse = Response<Post>;
などとして使うことができます。
Generic 型を使うことで、API の返り値など、ある程度共通の型をもった型を生成できます。
ちなみに Array 型の定義 Array<number>
や Array<string>
もジェネリクスです。
Union 型
後述する Intersection 型と似ているので、少しだけ注意が必要です。 これは、指定したいくつかの型のうちの 1 つの型であることを表します。
1
2
3
4
type Single = number | string;
const a: Single = 1; // OK
const b: Single = [1, 2]; // NG
また、指定したいくつかの型の、共通のプロパティにしかアクセスできず、アクセスしようとすれば型チェック時に怒られます。
1
2
3
4
const a: Single = "1";
a.toFixed(); // Errors! Cannot call `a.toFixed` because property `toFixed` is missing in `String` [1].
// toFixed は Number にしかなく、Stringにはないため怒られる
ただし、a が Number 型に属すると絞り込んだ後であれば、toFixed を呼び出すことができます
1
2
3
4
5
6
7
8
9
10
/* @flow */
type Single = number | string;
const a: Single = 1;
switch (typeof a) {
case "number":
a.toFixed(); // No errors!
break;
}
Intersection 型
Intersection 型は、指定した複数の型の性質をすべて持つ型を作ります。
1
2
3
4
5
6
7
8
9
10
11
12
13
// @flow
type A = { a: number };
type B = { b: boolean };
type C = { c: string };
type Arg = A & B & C;
function method(value: Arg) {}
method({ a: 1 }); // Cannot call `method` with object literal bound to `value` because property `b` is missing in object literal [1] but exists in `B` [2].
// $ExpectError
method({ a: 1, b: true }); // Cannot call `method` with object literal bound to `value` because property `c` is missing in object literal [1] but exists in `C` [2].
method({ a: 1, b: true, c: "three" }); // No errors!
この Arg 型を持つ値は A, B, C 型のプロパティを すべて 持っているということを表現できます。
Union 型で型付けられた値は、指定した複数の型の一部に属していればよかったのに対し、Intersection 型は指定された複数の型すべてのプロパティを持つ必要があります。
Typeof 型
JS の文法にもtypeof
演算子がありますが、Flow のtypeof
はそれとは異なります。Flow のtypeof
は、Flow で定義した Type を返してくれます。
この typeof
を使うことで、「ある型と同じ型」を作ることができます。
「ある型と同じ型」を作ることができれば、「ある型と同じ型」の値を持つオブジェクトの型の定義ができるようになります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// redux action
type StartFetchAction = {
type: "START_FETCH",
payload: string
};
const startFetch = (id: string): StartFetchAction => {
return {
type: "START_FETCH",
payload: id
};
};
// container
type DispatchAction = {
fetchData: typeof startFetch // No errors!
};
$ReadOnly
$ReadOnly
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Type1 = {
hoge: number
};
type ReadOnlyType1 = $ReadOnly<Type1>;
type ReadOnlyType2 = {
+hoge: number
};
const object1: ReadOnlyType1 = {
hoge: 1
};
console.log(object1.hoge); // No errors!
object1.hoge = 2; // Error Cannot assign `2` to `object1.hoge` because property `hoge` is not writable.
$Keys
$Keys
1
2
3
4
5
6
7
8
9
10
11
const countries = {
US: "United States",
IT: "Italy",
FR: "France"
};
type Country = $Keys<typeof countries>;
const italy: Country = "IT"; // No errors!
const nope: Country = "nope"; // Error Cannot assign `"nope"` to `nope` because property `nope` is missing in object literal [1].
References:
$Exact
Exact を利用することで、対象となるオブジェクトは、宣言された型と同一のキーと型を持つことが保証されます。余分なプロパティを受け取れないように縛ることができます。
1
2
3
4
5
6
7
8
type NormalType = { name: string };
type ExactType = $Exact<{ name: string }>;
let user: NormalType = { name: "taro" };
user = { ...user, age: 12 }; // No Errors!
let user2: ExactType = { name: "taro" };
user2 = { ...user2, age: 12 }; // Error! Cannot assign object literal to `user2` because property `age` is missing in `ExactType` [1] but exists in object literal [2].
$Exact
1
2
3
4
5
type ExactUser = $Exact<{ name: string }>;
type ExactUserShorthand = {| name: string |};
const user1: ExactUser = { name: "John Wilkes Booth" }; // No errors!
const user2: ExactUserShorthand = { name: "John Wilkes Booth" }; // No errors!
Exact を使う時、 Intersection 型の扱いに注意しましょう。
1
2
3
4
5
6
7
8
9
10
type StateProp = {|
isLoading: boolean,
data: Object
|};
type DispatchProp = {|
fetchData: void => void
|};
type Prop = StateProp & DispatchProp; // No errors!
としたとき、Prop は
1
2
3
4
5
type Prop = {
isLoading: boolean,
data: Object,
fetchData = void => void
}
と同じに なりません。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type StateProp = {|
isLoading: boolean,
data: Object
|};
type DispatchProp = {|
fetchData: void => void
|};
type Prop = StateProp & DispatchProp;
const expectedProp: Prop = {
// Error
isLoading: false,
data: {},
fetchData: () => {}
};
同じにしたければ、
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* @flow */
type StateProp = {|
isLoading: boolean,
data: Object
|};
type DispatchProp = {|
fetchData: void => void
|};
type Prop = {| ...StateProp, ...DispatchProp |};
const expectedProp: Prop = {
isLoading: false,
data: {},
fetchData: () => {}
};
としなければいけません。
Spread 演算子を使うと、型オブジェクトを展開できます。 型を組み合わせて新しい型を作りたいときによく使うテクニックです。
実務で型をつけるに当たって知っておきたいこと
この章から、実務で型をつけるときに行なっているプラクティスやテクニックを紹介します。 Air メイトはダッシュボードや管理画面としての役割を持つアプリケーションです。
使っているライブラリは以下の通りです。
用途 | 使用ライブラリ |
---|---|
FW | React |
状態管理 | Redux |
副作用管理 | redux-saga |
API Client | axios |
CSS in JS | styled-components |
selector | reselect |
このようなアプリケーションに対して Flow で型をつけました。
型推論と型注釈
当初、私は本当に型のことを何も理解していなかったので、
1
2
3
4
5
const hoge = (): string => {
return "piyo";
};
const foo: string = hoge();
のようなコードを書かないといけない、すべての変数に型をつけないといけないだなんて大変だなぁと思っていました。そうです、型推論というものを知りませんでした。
型推論とは、代入式などから、付くべき型を自動で推論してくれる機能です。 上のようなコードは、型推論という機能によって、代入される変数に型注釈を書く必要がなくなります。
1
2
3
4
5
const hoge = (fuga: number) => {
return "piyo";
};
const foo = hoge(1);
とするだけで、foo に string 型がつきます。
型推論に頼ることで、型注釈をたくさん書いていくという作業を行わなくてもよくなります。
ただ、型注釈をつけることにも利点があり、たとえばライブラリが返す値を受け取る変数に、自身で型注釈を入れるテクニックがあります。 型検査のアルゴリズムによりますが、型検査の結果、失敗し、エラー行がライブラリ内を指してしまう場合があります。 その場合、ライブラリの関数が返す値を受け取る変数に、ライブラリが返すであろう型を開発者が型注釈として宣言しておけば、ライブラリ内で型検査に失敗することなく、自身が書いたコード(ライブラリを呼び出したコード)上でエラーを吐きます。
Air メイトでは styled-components で作る一部のコンポーネントに型注釈を入れています。
1
2
3
const TooltipContent: Class<React.Component<*>> = styled.p`
font-weight: 400;
`;
私は、基本的には型推論に頼りながら、なにか不都合なことが起きると、必要なところに型注釈を入れるという開発をしています。
型は使い回す
型エイリアスを使って作った型は export
することができ、定義したファイル外でも使いまわすことができます。定義したファイル外でその型を使うときは import
で型を持ってくることができます。
1
2
3
4
5
// redux.js
export type StartFetchAction = {
type: "START_FETCH"
};
1
2
3
4
5
6
7
// container.js
import { type StartFetchAction } from "./redux";
type DispatchAction = {
fetch: StartFetchAction
};
ライブラリに型をつける
型情報を取得する
Flow ではライブラリの型定義が、flow-typedとして管理・保存されています。これはさまざまなライブラリの型を集めた場所で、
1
flow-typed install {library}
とすることで型をダウンロードできます。
また利用しているライブラリすべての型を install するためには、
1
flow-typed install
とします。
ダウンロードした型は flow-typed フォルダーに自動で保存されます。
この flow-typed フォルダーに該当する型があると、アプリケーションの中でライブラリの型を使うことができます。
型情報がないライブラリに型をつける
もし、ライブラリの型が flow-typed から見つからない場合はスタブを作ります。
1
flow-typed create-stub {library}
とすることで、そのライブラリが持つすべてのメソッドに any 型をつけたファイルを、flow-typed ディレクトリに吐いてもらえます。有名なライブラリだけど、型がないライブラリなどに使えます。Air メイトだと、react-beautiful-dndに使っています。
TypeScript の場合は?
TypeScript の場合、ライブラリの型情報も npm で管理されています。@types/{ライブラリ名}
という名前で install できます。
1
$ npm install react @types/react
ちなみに、flow-typed にはないが npm には型情報があったといったこともあり、これから新規開発を行う場合は、 Flow より TypeScript を選択した方が良いかもしれません。
React Redux で開発されたアプリケーションを型で縛るプラクティス
この章では React Redux の実務でどのように型をつけているかということを紹介します。 なお、以下に出てくるコードは、少し変数名を変えたものではあるものの Air メイトのソースコードそのままです。
props, state, store は ReadOnly で縛る
React が管理している props は原則として書き換えてはいけません。 また state や store も破壊的な変更は禁止されています。
とはいえ、これらの書き換えは、行おうと思えばできてしまいます。 store にないプロパティを勝手に reducer から増やすということもできてしまいます。
そこで、静的な検査で、そのようなことが起きていないかチェックしましょう。ReadOnly
1
2
3
4
type Props = {|
+isLoading: boolean,
+userInfo: $ReadOnlyArray<UserInfo>
|};
(※ ||は $Exact
Props の型は種類ごとに作り、マージする
React における props は、親から渡されるもの、react-redux から渡されるものなど多岐に渡ります。これらは分けて扱った方がが管理しやすいため、それぞれ type alias を設定し、それらを合併した型を Prop 型として定義しましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// redux actionに関するprops. mapDispatchToPropsに依存する.
type DispatchProps = {|
+fetchMenuTag: typeof startFetchMenuTag,
+toggleModal: typeof toggleModal
|};
// 親コンポーネントから渡されるprops.
type OwnProps = {|
+isInitError: boolean
|};
// storeに関するprops. mapStateToPropsに依存する.
type StateProps = {|
+visibleMenu: $ReadOnlyArray<Menu>,
+MenuTags: $ReadOnlyArray<string>,
+selectedMenu: ?Menu,
+isOpenModal: boolean
|};
type Props = $ReadOnly<{| ...DispatchProps, ...OwnProps, ...StateProps |}>; // 合併する
この書き方は何が嬉しいでしょうか。まず、見通しが良くなるという点があります。それ以外にも、react-redux の connect 時の型と、コンポーネント内の型を揃えられるとうい利点があります。
たとえば、mapStateToProps
をこう定義しましょう。
(TStore は redux の store の型です。)
1
2
3
4
5
6
7
type StateProps = {|
+isLoaded: boolean
|};
const mapStateToProps = (state: TStore): StateProps => ({
isLoaded: state.A.isLoaded
});
このように定義しておけば、mapStateToProps
とコンポーネント内の型の過不足に気づくことができます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type StateProps = {|
+isLoaded: boolean
|};
const mapStateToProps = (state: TStore): StateProps => ({
isLoaded: state.A.isLoaded,
hoge: 1 // Cannot return object literal because property hoge is missing in StateProps [1] but exists in object literal
});
// StatePropsを型注釈しない場合
const mapStateToProps = (state: TStore) => ({
isLoaded: state.A.isLoaded,
hoge: 1 // No errors!
});
nullable と推論されたが、開発者が null にならないことを知っている場合、型検査のエラーを無視する
実務においては、返り値は nullable なものだけど、実際の値は絶対に存在することを開発者がわかっているケースなどがあります。たとえば DOM 要素の型がそのケースに当てはまります。
1
2
// $FlowFixMe これはnullableなのでエラーになる
const elForPortal: HTMLElement = document.getElementById("tooltip-root");
このとき、elForPortal を使うときは、毎度 null チェックが必要になり、利便性が下がります。 そこで、返り値は nullable だけど、絶対に値が存在することを明示する方法を使いましょう。 それは一度 any 型として宣言してしまうことです。
1
const elForPortal: HTMLElement = (document.getElementById("modal-root"): any);
一度 any 型として存在することを示した上で、HTMLElement 型として宣言しなおすことで、HTMLElement 型として elForPortal を扱えます。
React で実 DOM を取得するだなんてと思うかもしれませんが、React は v16.3 で Portal という、モーダルやツールチップの重ね順などを制御するために便利な機能が追加されており、その機能を使うために document.getElementById()
がよく登場しています。実務でもよく使っているテクニックです。
検査を無視する方法には、型エラーが出た箇所の上に // $FlowFixMe
とつけることでも解消する方法もあります。
1
2
// $FlowFixMe
const stra: string = 1;
redux の action 型を、action creator の型の Union 型で作る
まず、昔の私は、
1
2
3
4
type Action = {
type: string,
payload?: any
};
という型を付けて、
1
2
3
4
5
6
export const actionCreator1 = (hoge: string): Action => {
return {
type: "HOGE",
payload: hoge
};
};
のような action creator を作っていました。
これを作ったとき自分は「なんか間違ったことをしているかも」と思っていました。 なぜなら payload の束縛があまりにも弱すぎるからです。action creator は reducer や middleware から何回も呼ばれる恐れがあるので、なるべく型で縛ってしまいたい気持ちがありました。
そして、もっと良い型の作り方はやはりありました。 それは、各 action creator を Literal 型で縛ってしまい、それらの Union 型を Action Type として定義する方法です。
(※ 公式にも書いてありました、公式はしっかり読みましょう(自戒))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export type FetchStoreAttractAnalysisStartAction = {|
+type: "user/FETCH_STORE_ATTRACT_ANALYSIS_START"
|};
export type FetchStoreAttractAnalysisSuccessAction = {|
+type: "sys/FETCH_STORE_ATTRACT_ANALYSIS_SUCCESS",
+payload: AttractAnalysis
|};
export type Action =
| FetchStoreAttractAnalysisStartAction
| FetchStoreAttractAnalysisSuccessAction;
export const fetchStoreAttractAnalysisStart = (): FetchStoreAttractAnalysisStartAction => {
return { type: FETCH_STORE_ATTRACT_ANALYSIS_START };
};
export const fetchStoreAttractAnalysisSuccess = (
data: AttractAnalysis
): FetchStoreAttractAnalysisSuccessAction => {
return { type: FETCH_STORE_ATTRACT_ANALYSIS_SUCCESS, payload: data };
};
こうすることで fetchStoreAttractAnalysisSuccess
のような action creator を reducer で呼ぶときに、action の type や payload に補完が効くようになります。
reducer での補完は強力で、switch 文で type ごと呼び出す action が絞り込まれて入れば、case のブロック内の action はどの型なのかがわかっており、payload の型補完まで効くようになります。
combineReducer するときに store の型も一緒に作る
大規模開発になってくると、ducks パターンにしたがって、module 単位で Reducer を分割して、開発するケースがあります。もし、reducer を分割するのであれば store も分割されているはずで、アプリケーション単一の store の型がわかりづらくなります。
そして React 開発においては、アプリケーション全体の state を知りたい場面は多々あります。たとえば、mapStateToProps
や、
1
2
3
4
5
6
const mapStateToProps = state => {
return {
isExtraItemsLoading: state.customizeDailyReport.isExtraItemsLoading,
isExtraItemsLoaded: state.customizeDailyReport.isExtraItemsLoaded
};
};
selector 層では知りたいのではないでしょうか。
1
2
3
import { createSelector } from "reselect";
export const storesSelector = state => state.user.data.stores || [];
そこで、reducer を combine するタイミングで store の型もまとめてしまいましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// @flow
import { combineReducers } from "redux";
import storeIndicesSummary from "./storeIndicesSummary";
import storeIndicesDetail from "./detail";
import { type State as DetailState } from "./detail";
import { type State as StoreIndicesSummaryState } from "./storeIndicesSummary";
export type TStore = {|
+detail: DetailState,
+summary: StoreIndicesSummaryState
|};
export default combineReducers({
summary: storeIndicesSummary,
detail: storeIndicesDetail
});
あとはこの State を、他の箇所から呼び出すだけで、型をつけることができます。
1
2
3
4
5
6
7
8
9
// @flow
import { type TStore } from "../store";
const mapStateToProps = (state: TStore) => {
return {
isExtraItemsLoading: state.customizeDailyReport.isExtraItemsLoading,
isExtraItemsLoaded: state.customizeDailyReport.isExtraItemsLoaded
};
};
もし mapStateToProps
で store にないプロパティへアクセスしようとすれば、エラーを吐いてくれます。
1
2
3
4
5
6
7
8
9
10
// @flow
import { type TStore } from "../store";
const mapStateToProps = (state: TStore) => {
return {
isExtraItemsLoading: state.customizeDailyReport.isExtraItemsLoading,
isExtraItemsLoaded: state.customizeDailyReport.hoge
// Error Cannot get state.customizeDailyReport.hoge because property hoge is missing in State [1].
};
};
container の props に含まれる action creator の型は、typeof を使って作る
1
2
3
4
type DispatchProps = {|
+selectStore: typeof dataOutputActions.selectStore,
+cancelStore: typeof dataOutputActions.cancelStore
|};
1
2
3
4
5
6
7
8
export type SelectStoreAction = {|
+type: "output/SELECT_STORE",
+payload: string
|};
export const selectStore = (storeId: string): SelectStoreAction => {
return { type: "output/SELECT_STORE", payload: "1234" };
};
typeof はオブジェクトから型を作ってくれます。そのため、ある対象にとあるオブジェクトと同じ型を付けたい場合に重宝できます。上の例だと、dataOutputActions.selectStore
の型 SelectStoreAction
を直接つけても良いのですが、そのとき dataOutputActions.selectStore
の型が変わったとき、selectStore
の型も一緒に更新しないと、古いままになってしまう問題があります。一方で、typeof
を使っていると、同じ型であることも保証できるので、束縛力もより強いです。
redux-saga と併用する際は、yield された値に型注釈をつける
redux-saga が期待するジェネレーター関数は Generator<Effect, T, any>
と型が付きます。そしてこの最後の any はジェネレーターの next()
の引数に渡される値の型です。
そのため、redux-saga が Effect を解決した結果の値の型は any で潰されており、yield 式で受け取れる値の型を Flow は知ることができません。 そのため、明示的に返り値に型注釈をつけないと、型の恩恵を受けながら変数を利用することができません。
幸い、redux module で action creator や action に型をつけているのであれば、それらを import して使いまわすことで対処ができます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { all, fork, take, put, select } from "redux-saga/effects";
import {
types,
actions,
type StartPostNoteAction
} from "../modules/postCustomize";
import dailyReportAPI from "../services/dailyReport";
import {
type Note,
type Report,
type APIResponse,
type APIError
} from "../typedef";
function* postNoteSaga() {
while (true) {
const action: StartPostNoteAction = yield take(types.START_POST_NOTE);
const data: Note = action.payload;
const {
payload,
error
}: { payload: APIResponse<Report>, error: APIError } = yield call(
dailyReportAPI.postNote,
data
);
if (payload && !error) {
yield put(actions.successPostNote(payload.result));
} else {
yield put(actions.failPostNote(error.message));
}
}
}
まとめ
いかがでしたでしょうか。意外と React アプリケーションをどう型で縛るかという情報はあまり出てこないので、参考になったのではないでしょうか。もし間違いや改善点がございましたら、@sadnessOjisanなどに DM いただきたいです。私もまだまだ手探りなので、皆さんのアドバイスをいただきたいです。それじゃノシ