TypeScriptはJavaScriptに静的型による型チェックを加えた言語で、Microsoftによって開発されています。TypeScriptの登場以降その人気上昇は留まるところを知らず、Webアプリの開発に採用される例も増えているようです。TypeScriptはJavaScriptと同じ構文・言語機能を持ち、唯一の違いは型がある点、つまり型が書けて型チェックがあるというところです1。
また、ReactはJavaScript向けのUIライブラリで、HTML要素やコンポーネントを第1級オブジェクトとして扱いUIを構築するスタイルが特徴的です。こちらはFacebookが開発しています。
この最高のプログラミング言語(個人の感想です。)に最高のライブラリ(個人の感想です。)を組み合わせるのがWebアプリ開発におけるまさに唯一無二の解であることはかけらも疑う余地が無いのですが(個人の感想です。)、そのときにTypeScriptの真価を発揮させるのはそう簡単なことではありません。
そこでこの記事ではTypeScript wayと称して、TypeScriptの真の力を発揮させるための方法論、すなわち型ファーストなTypeScriptプログラミングを説明します。また、「型オタクの戯言ではなく実用的な話をしているんですよ」ということをアピールしたいのでReactを例に用いて説明します。Reactを知らない方には付いてくるのがやや難しいかもしれませんが、考え方自体はReact以外にも応用できるでしょう。
ちなみに、React本体にはTypeScript用型定義は同梱されていません。DefinitelyTypedで管理されている@types/reactという型定義パッケージを使うことになります。さすが有名パッケージだけあって、これはかなり頑張って作られています。安心して使いましょう。
話を戻しますが、そもそもTypeScriptは型の部分を除けばJavaScriptと同じという特徴を持つので(enumやnamespaceなどの黒歴史は見ないふりをしてあげましょう)、TypeScriptでのコーディングというのは基本的にはJavaScriptを書くのと同じになると思われるかもしれません。つまり、JavaScriptコードを書くのと同じ考え方でTypeScriptコードを書くことができ、ついでにちゃんと型を書いてあげれば立派なTypeScriptコードの完成です。
しかしながら、このやり方はJavaScriptファーストであり、型がオマケ扱いです。それゆえに、実は必ずしもこれがベストプラクティスとは限りません。
型ファーストというのは、型があることを前提にプログラムを設計し、型の恩恵を最大限受けるための書き方をすることで達成されます。型ファーストでプログラムを書くと、型のことを考えずに書いたJavaScriptプログラムとは異なる形になることがあるのです。
それゆえに、JavaScriptからTypeScriptへの移行案件では、型ファーストを採用するのに「ただ型を書くだけ」という範囲を逸脱するリファクタリングが必要となり難しいかもしれません。どちらかといえば、この記事の内容は新規開発でTypeScriptを使用する際に力を発揮するでしょう。
そういう事情もありますから、この記事の方法が唯一絶対の正解だ、などと主張するつもりはありません。型ファーストなプログラムが最もTypeScriptの力を活かせるのは間違いありませんが、それには相応の苦労が伴います。TypeScriptの力をどれくらい活かすのか、それはTypeScriptを使う人が決めることなのです。
なお、今回の話は結局「型の力を活かそう!」という話ですので、型システムが強い言語に慣れ親しんでいる方にとってはそんなに目新しい話ではないかもしれません(特にADTを持つ言語)。主にJavaScript畑からTypeScriptに参入してきた人をターゲットにしているのでその点はご了承ください。
前置きが長くなりましたが、いよいよ本題に入りTypeScript wayなプログラムの書き方を説明していきます。いくつかのケーススタディを通して解説することにしましょう。
データをロードする場合
Webアプリケーションで頻出のパターンは「APIからデータをロードして表示する」というものです。まず型のことは忘れてこれを作ってみましょう。ロードしたデータはコンポーネントのstateに保存しておくことにします2。
class App extends React.Component {
state = {
isLoading: true, // 初期状態はロード中
user: undefined, // ユーザーデータ
};
componentDidMount() {
loadUserData()
.then(user => {
this.setState({
isLoading: false,
user,
});
});
}
render() {
if (this.state.isLoading) {
return <p>読み込み中</p>
}
// 読み込み済
return <p>こんにちは、{this.state.user.name}さん!</p>;
}
}
多分だいたいこんな感じになるかと思います。今読み込み中かどうかを表すisLoadingフラグを用意しておき、読み込み終わったらthis.state.userにそのデータを入れると同時にisLoadingフラグをfalseにします。
では、これにそのままTypeScriptで型を付けてみましょう。これは型ファーストではない例になります。
interface State {
isLoading: boolean;
user?: {
name: string;
}
}
class App extends React.Component<{}, State> {
state: State = {
isLoading: true, // 初期状態はロード中
user: undefined, // ユーザーデータ
};
componentDidMount() {
loadUserData()
.then(user => {
this.setState({
isLoading: false,
user,
});
});
}
render() {
if (this.state.isLoading) {
return <p>読み込み中</p>
}
// ↓ここで型エラーが発生!!!!!(this.state.userがundefinedかもしれないので)
return <p>こんにちは、{this.state.user.name}さん!</p>;
}
}
this.stateを表すState型を定義しました。isLoadingはbooleanで、userは省略可能な(undefinedかもしれない)オブジェクトになっています。この定義になっているのは、コンポーネントが作られた瞬間にはuserにはundefinedが入っていることを表しています。実際userの値が手に入るのはloadUserData()が終了してからなので、それまでにuserにundefinedを入れておくのはまあ妥当ですね。
問題は、render()の最後の行で型エラーが発生しているところです。根本的な原因は、プログラマは「isLoadingがfalseならばuserにはちゃんとデータが入っている」ということを知っているのに、TypeScriptはそれが分かっていない点です。まあ、Stateの型宣言を見てもそんなことは書いていないので仕方ありません。
対処法としてはthis.state.user!.nameのようにエラーを握りつぶすなどの手がありますが、それではTypeScriptに黒星をひとつ付けられる結果となります。
より根本的な解決は、型をより正確に書くことでTypeScriptに分かってもらうことにより成し遂げられます。ただ、そのためには型定義だけでなくコードをちょっと変える必要があります。そうして達成されるのが型ファーストなTypeScript wayなのです。では、今回の例をTypeScript wayで書くとどうなるでしょうか。“正解”を発表します。
interface State {
user:
{
isLoading: false;
name: string;
} | {
isLoading: true;
};
}
class App extends React.Component<{}, State> {
state: State = {
user: {
isLoading: true
}
};
componentDidMount() {
loadUserData().then(user => {
this.setState({
user: {
isLoading: false,
name: user.name
}
});
});
}
render() {
if (this.state.user.isLoading) {
return <p>読み込み中</p>;
}
// ↓型エラーが発生しない
return <p>こんにちは、{this.state.user.name}さん!</p>;
}
}
ポイントはState型の定義が変わっている点です。userプロパティの定義がunion型となっていますね。union型は「または」の意味ですから、これは「{ isLoading: false; name: string }型または{ isLoading: true }型」という意味です。前者は「isLoadingの値がfalseでnameに文字列が入ってるオブジェクト」で後者は「isLoadingの値がtrueのオブジェクト」ですね。言い換えると、これは「isLoadingが真偽値(trueまたはfalse)で、falseのときはさらにnameに文字列が入っているオブジェクト」ということになります。
isLoadingはuserの下に入る形となって元とは変わってしまっていますが、その代わりに「isLoadingがfalseのときのみnameが存在する」ということを型で表現できています。
実際の運用を見てみましょう。最初はthis.state.userに{ isLoading: true }というオブジェクトが入っています。これは型定義通りですね。また、loadUserData()が終わったら{ isLoading: false, name: user.name }という別のオブジェクトをthis.state.userに入れています。これも前述の型定義に合致していますね。
このデータを使う側であるrender関数の中身も見てみます。最初にthis.state.user.isLoadingがtrueのときはreturnする処理が入っています。これにより、TypeScriptはそれ以降でthis.state.user.isLoadingがfalseであることを理解します。
この段階では、this.state.user.isLoadingがfalseであることが判明したことで、this.state.userの型がunion型の2つの候補のうち{ isLoading: false; name: string }の方であることが分かっています。よって、this.state.user.nameというプロパティアクセスはエラーとなりません。技術的にはここでunion型の絞り込みという挙動が発生しています。union型はTypeScriptからのサポートが手厚く、型の絞り込みを通してロジックの記述をサポートしてくれるのです。
このように、ロジックを漏れなく型で記述することで、TypeScriptを騙すことなく型エラーを消すことができました。ただ、その過程でコード自体を少し変える必要がありました。
isLoadingをuserの中に突っ込むというやり方は型ファーストな考え方をしていればそんなに変な発想ではありませんが、JavaScript脳だとあまり浮かばないのではないかと思います。このように、TypeScriptの型が持つ機能を理解しそれを活用するのがTypeScript wayです。
ということは、TypeScriptの型がどんな機能を持っているのか知るのが非常に重要ですね。例えば以下の記事などはおすすめです(宣伝)。
ちなみに、別解としてはそもそもisLoadingを廃止してuser?: { name: string }だけにするという方法も無いわけではありません。userがまだ存在しない(undefined)な場合を読み込み中として扱うというものです。これも場合によってはありですが、コードの意味が分かりにくいので多用は避けたほうがいいかもしれません。また、この方法はすぐにはローディングが始まらない場合やローディング中のみ存在する情報(進捗率とか)がある場合に応用ができません。
同時に指定できないpropsがある場合
TypeScriptでReactを書く場合、propsも型を指定できます。基本的には、propsとして指定可能な属性名とその型をまとめたオブジェクトの型をpropsの型として指定しますね。
では、あるコンポーネントはfooとbarという2つのpropsがあるとしましょう。この2つのpropsはどちらか一方しか指定してはいけないという条件があるとします。この条件を型で表したい場合はどうすればよいでしょうか。
両方渡されても使う側が無視すればいいということにしてしまえばそれまでですが、バグの元なので折角なら型エラーで弾いてしまいたいですね。TypeScriptの型システムを最大限活かすというのには、型エラーで弾けるものは型エラーで弾いてしまおうという方向性も含まれています。
実はこの場合もunion型の出番です。「fooを指定する場合のpropsの型」と「barを指定する場合のpropsの型」を作ってunion型で繋げましょう。
/**
* fooを指定する場合の型
*/
interface FooProps {
foo: number;
bar?: undefined;
}
/**
* barを指定する場合の型
*/
interface BarProps {
foo?: undefined;
bar: string;
}
type Props = FooProps | BarProps;
class MyComponent extends React.Component<Props> {}
// これはOK
const e1 = <MyComponent foo={123} />;
// これもOK
const e2 = <MyComponent bar="foobar" />;
// これは型エラー!!!!!!
const e3 = <MyComponent foo={1234} bar="hi" />;
type Props = FooProps | BarPropsというようにpropsの型を定義しています。これによりMyComponentに渡されるpropsたちはFooPropsかBarPropsのどちらかに合致することが求められます。FooPropsはfooを指定するときの型で、BarPropsはbarを指定するときの型ですね。
例えばFooPropsを見るとfooの型をnumber型とするほかに、bar?: undefined;という指定が入っています。これは「barを指定してはいけない」、つまり「fooのみを指定する」ことに相当します(正確にはbar={undefined}が許可されることになりますが、この場合の挙動はbarを指定しなかったのと同じなので許容範囲ということにしています)。
こうすることでPropsは「fooのみを指定できる型」であるFooPropsと「barのみを指定できる型」であるBarPropsのどちらかという意味になり、両方指定できる可能性を排除しています。ただ、実際にやってみると分かりますがエラーメッセージが多少分かりにくいのが玉に瑕ですね。
propsを使う側の書き方
実は、使う側もすこし工夫が必要です。単純に次のようにすると型エラーとなってしまいます。
class MyComponent extends React.Component<Props> {
render() {
const { foo, bar } = this.props;
if (foo != undefined) {
return <p>foo is {foo}</p>;
} else {
// ↓ここで型エラーが発生(bar が string | undefinedなので)
return <p>bar's length is {bar.length}</p>;
}
}
}
Propsの定義によればfooがundefinedのときはbarはundefinedではないはずですが、TypeScriptは残念ながらこれを理解できません。この場合は次のようにすれば解決です。
class MyComponent extends React.Component<Props> {
render() {
const props = this.props;
if (props.foo != undefined) {
return <p>foo is {props.foo}</p>
} else {
// 型エラーが発生しない
return <p>bar's length is {props.bar.length}</p>
}
}
}
この例では、this.propsをさらにfooとbarに分解するのをやめてprops.fooやprops.barとして扱っています。こうすることで、さっきの型エラーが解消されます。
これは、あくまでunion型を持つのがpropsであることが理由です。propsはProps型、すなわちFooProps | BarProps型を持っていますが、props.fooがundefinedであることが分かった時点でpropsはBarProps型に決定されます。これにより、else節ではprops.barがstring型となり、props.bar.lengthでエラーが発生しません。
このような挙動はあくまで「propsという一つの変数の型の推論」を通してサポートされます。propsをfooとbarという2つの変数に分解した時点でこのような推論が行えなくなってしまうのです。
この例のように、union型をうまく扱いたい場合はそのunion型を持つ変数を直に扱う(今回の例ではthis.propsを分解せずに扱う)ことが必要になります。これも「型のことを考えたコードを書く」の一例と言えるでしょう(TypeScriptがもっと賢ければこれは不要になるというのが引っかかりますが、この挙動をサポートするのは非現実的なので仕方ありません)。
常に指定すべきpropsもある場合
ここまでの内容は「fooとbarのどちらか1つのみを指定」という例で説明してきましたが、さらに複雑にして「fooとbarはどちらか1つのみ、それ以外は常に指定」みたいな場合もあります。すなわち、どちらの場合も共通で常に指定すべきpropsがある場合ですね。この場合はintersection型を使いましょう。例えばhogeとfugaは常に欲しいみたいな場合はこうします。
/**
* 共通のpropsの型
*/
interface CommonProps {
hoge: string;
fuga: string;
}
/**
* fooを指定する場合の型
*/
interface FooProps {
foo: number;
bar?: undefined;
}
/**
* barを指定する場合の型
*/
interface BarProps {
foo?: undefined;
bar: string;
}
type Props = CommonProps & (FooProps | BarProps);
&は「かつ」という意味なので、これはCommonPropsを満たし、かつ(FooProps | BarProps)も満たすということになります。とても面白いですね。
余談:同じことを何度も書きたくない問題を型パズルで解決
ところで、上記の解決策を見て不満に思ったことがある方がいるかもしれません。FooPropsとBarPropsの両方にfooとbarが登場しており、これらのプロパティ名を2回書く必要があるのが微妙ですね。何回も同じものを書くのはメンテナンス性も低いし書き間違えるかもしれません。
実はこの問題はTypeScriptのフルパワーを使うと解決できます。詳しい説明は省きますが、こうすればできます。
/**
* propsの一覧(このうち1つのみ指定可能になる)
*/
interface AllProps {
foo: number;
bar: string;
baz: boolean;
}
type Take<K extends keyof AllProps> = Pick<AllProps, K> &
Partial<Record<Exclude<keyof AllProps, K>, undefined>>;
type DistributeTake<K> = K extends keyof AllProps ? Take<K> : never;
type Props = DistributeTake<keyof AllProps>;
class MyComponent extends React.Component<Props> {}
// これはOK
const e1 = <MyComponent foo={123} />;
// これもOK
const e2 = <MyComponent bar="foobar" />;
// これもOK
const e3 = <MyComponent baz />;
// これは型エラー!!!!!!
const e4 = <MyComponent foo={1234} bar="hi" />;
// これも型エラー!!!!!!
const e5 = <MyComponent bar="hi" baz />;
今回はfoo, barのほかにbazも増やして、この中のどれか1つしか指定できないという設定にしました。AllPropsに全部のpropsを定義しておいてごにょごにょと型をいじるとあら不思議、目的が達成できています。
このコードを理解したいという方は筆者が書いた以下の記事で勉強しましょう(宣伝)。
クラスコンポーネントの罠とReact Hooksの話
Reactのユーザー定義コンポーネントは(今は使われていない古いやつを除けば)2種類あります。すなわち、クラスコンポーネントと関数コンポーネントです。従来クラスコンポーネントのほうが色々な機能があることから広く使われてきましたが、2019年2月に登場したReact 16.8ではHooksが導入され、関数コンポーネントも機能的には引けを取らなくなりました。
- 筆者が書いたHooksの記事もぜひご覧ください:🎉React 16.8: 正式版となったReact Hooksを今さら総ざらいする
実際のところ、「ウチはクラスコンポーネントで回してるからそんなにHooksを使っていない」とか「学習コストがかかるのでHooksを使わないで済むならそれに越したことはない」といったことを思っている方もいるでしょう。しかし、型ファーストの観点からはやはりHooksを受け入れて関数コンポーネントを使うのがおすすめです。それは、クラスコンポーネントよりも関数コンポーネントのほうがTypeScriptと相性がいいからです。
つまり言いたいことは、型のためと思って頑張ってHooksくらい身につけようぜということです。ということで、旧来のクラスコンポーネントだとどの辺りが相性悪いのかを少し紹介します。一言でいえば随所に余計な型註釈が必要な点がいまいちです。
stateの初期化
stateというのは初期値が必要です。クラスコンポーネントで公式に推奨されているのは次のようにプロパティ宣言を使う方法のようです。
interface Props {
foo: string;
}
interface State {
bar: string | undefined;
}
class MyComponent extends React.Component<Props, State> {
state = {
bar: undefined
};
// ...
}
実は、この時点ですでにまずいです。上のMyComponentにおいてthis.stateの型はどうなっているでしょうか。「React.Component<Props, State>を継承しているのだからthis.stateは当然Stateである」と思うかもしれませんが、それは間違いです。実はこの場合this.stateの型はstate =の右辺の式から推論されて{ bar: undefined }になります。これがクラスコンポーネントの罠です。この場合すぐ気づくと思いますが、anyが紛れ込んだりすると話がとても厄介になります。
これは継承という機構を使っていることが原因で、あるクラスを継承したクラスはプロパティの型などを(より厳しくなる方向に)上書きする可能性があります。TypeScriptはそれを考慮してstateプロパティの型を推論し直しているのです。
これを直すひとつの方法は型註釈をちゃんと付けることです。
class MyComponent extends React.Component<Props, State> {
state: State = { // ←型註釈がついた
bar: undefined
};
// ...
}
しかし、これはStateと2回書いているのが良くないですね。
もうひとつの方法は、コンストラクタの中でstateを初期化してthis.stateに代入する形にするという方法です。
class MyComponent extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
bar: undefined
}
}
// ...
}
この場合はstateというプロパティを宣言するのではなく既存のthis.stateに代入するという形になり、stateの型が推論され直すという挙動は防ぐことができます。
ただ、こちらの方法にも問題点があります。コンストラクタ宣言とかsuper(props);などを書く必要がありタイプ数が多いという点3、そして今度はPropsを2回書かないといけないという点です。
これはクラスコンポーネントがクラスを使用していることが根本にあり、そしてTypeScriptの仕様も別に変なものではないのでクラスコンポーネントを使用している限り仕方ないと言わざるを得ません。
では根本的な解決はどうするかというと、そう、Hooksを使うのです。Hooksを使うとこうなります。
const MyComponent = (props: Props) => {
const [bar, setBar] = useState<string | undefined>(undefined);
// ...
};
この場合はuseStateの型註釈は消せないのですが(undefinedという式からstring | undefined型を推論するのはさすがに無理があるため)、それでもbarというステートの型を書くのは1箇所だけになっていますから、これでまあ十分だと思います。
余談:React.FunctionComponent型を活用しよう
上記のMyComponent型ですが、次のような宣言方法もあります。
const MyComponent: React.FunctionComponent<Props> = props => {
// 中身はさっきと同じ
};
MyComponentに型註釈が付きました。その代わり、関数式の引数propsから型註釈が消えています。これもPropsと書くのは1回だけだしいい感じですね。なお、React.FunctionComponentにはReact.FCという省略形が用意されています。好きな方を使いましょう。
こちらの方法には2つの利点があります。1つは**childrenが使用できる点、そしてもう1つは返り値の型が宣言時に検査される**点です。
1つ目については、React.FunctionComponentがいい感じの定義になっているため、このように型註釈付きで宣言した関数の引数は次のようにchildrenが使用可能です。
const MyComponent: React.FunctionComponent<Props> = props => {
// ...
return <div>{props.children}</div>;
};
先ほどのように(props: Props) => { ... }という宣言の場合はこうもいきません。Propsの中に自分でchildrenを含める必要があります。
2つ目についてですが、実はどんな関数でも関数コンポーネントになることができるわけではありません。特に、関数コンポーネントになるためには返り値が特定の型(React.ReactElement | null)である必要があります。型註釈を付けることでこの条件を満たさない関数コンポーネントの宣言をTypeScriptが弾いてくれます。
一方、型註釈がない場合はMyComponentはどこからどう見てもただの関数であり、(返り値にReact.ReactElement | nullのような型註釈をわざわざ書かない限りは)返り値がどんな型であってもエラーは起きません。代わりに、その変な関数を関数コンポーネントとして使用しようとした段階で初めて型エラーが発生します。これはバグに気づくのが遅れる可能性がありますからあまりよろしくないですね。
以上の2つの理由からReact.FunctionComponentの利用がおすすめです。
その他のクラスメソッド
話を戻しますが、クラスコンポーネントを使う場合はstate以外にも型註釈が必要になります。例えばcomponentDidUpdateはprevProps, prevState的な引数をとりますが、これの型をちゃんと明示しないとエラーになります(noImplicitAnyありの場合)。理由はすでに説明したstateの場合と同じです。
class MyComponent extends React.Component<Props, State> {
// ↓引数に型註釈を書かないとエラー
componentDidUpdate(prevProps, prevState) { }
}
TypeScriptがもうちょっと気を利かせてくれてもいいような気がしないでもないですが、こういうケースに対して合理的に気を利かせるのは意外と難しいので仕方がありません。
更新するstateを動的に決めたいとき
たまにこんなコンポーネントがあります(重要な部分だけ書いているので何となく雰囲気で察してください)。
interface State {
data1IsLoading: boolean;
data1?: Data1;
data2IsLoading: boolean;
data2?: Data2;
}
class BigComponent extends React.Component<{}, State> {
state: State = {
data1IsLoading: true,
data2IsLoading: true,
};
componentDidMount() {
// data1とdata2の読み込みを行う
this.load("data1", loadData1);
this.load("data2", loadData2);
}
/**
* 与えられた関数でデータを読み込んで与えられた名前のstateに突っ込む関数
*/
load(stateName: string, loader: () => Promise<any>) {
loader().then(data => {
this.setState({
[stateName + "IsLoading"]: false,
[stateName]: data,
} as any); // ←ここは型エラーで怒られるのでanyでごまかす
});
}
// ...
}
つまり、これはデータの読み込みを複数行うでかいコンポーネントですが、「データを読み込んでloadingをfalseにしつつstateを更新する」というロジックを切り出してloadというメソッドにしています。
もちろんこれはTypeScriptとの相性がよくありません。型ファーストで書くべきではない例です。根本的にまずいのがどこかというと、stateName + "IsLoading"です。TypeScriptでは文字列演算の結果はどうあがいてもstring型であり(より具体的なリテラル型になったりはしない)、それをstateのプロパティ名に使ってstateをアップデートしようとするのは無理筋です。
まあ、これに対する対処法は既に解説済みです。こうすればいいですね。
type MaybeLoading<T> =
| {
isLoading: false;
data: T;
}
| {
isLoading: true;
};
interface State {
data1: MaybeLoading<Data1>;
data2: MaybeLoading<Data2>;
}
class BigComponent extends React.Component<{}, State> {
state: State = {
data1: { isLoading: true },
data2: { isLoading: true },
};
componentDidMount() {
// data1とdata2の読み込みを行う
this.load("data1", loadData1);
this.load("data2", loadData2);
}
load(stateName: string, loader: () => Promise<any>) {
loader().then(data => {
this.setState({
[stateName]: {
isLoading: false,
data,
},
} as any); // ←まだ型エラーが出る
});
}
// ...
}
これでstateName + "IsLoading"などというものは消えました。しかし、まだ変数stateNameをキーに用いたstateの更新が残っています。また、引数stateNameやloaderの定義が適当すぎます。実は、これくらいならTypeScriptの力でどうにかできそうです。そう、keyof型を使うのです。
するとまあこんな感じになります。
type DataForKey<K extends keyof State> = State[K] extends MaybeLoading<infer T> ? T : never;
class BigComponent extends React.Component<{}, State> {
// (中略)
load<K extends keyof State>(stateName: K, loader: () => Promise<DataForKey<K>>) {
loader().then(data => {
this.setState<K>({
[stateName]: {
isLoading: false,
data,
} as const
}); // ←まだ型エラーが出る
});
}
}
loadはKを何らかのstateのプロパティ名とし、それに合致する文字列のみを引数stateNameとして受け取るようになりました。また、loaderについてもそれと対応する型のデータを取得する関数でないと受け付けないようになりました。これならじつに安心ですね。
this.setStateの型引数Kを明示する必要があったりするのが多少残念です。as constも、[stateName]という動的なプロパティ名のせいでcontextual typingが効いていない(このあたりの用語について詳しく知りたい方はTypeScriptの型推論詳説をご覧ください)のが原因で必要になっています。
結局この例で言いたかったことも、TypeScriptが何とかできる形でコンポーネントを書こうということです。型に縛られるとできることが制限されると思うかもしれませんが(そしてある意味それは正しいですが)、keyofなどのTypeScriptの機能を使うことでこれくらいは頑張れます。
余談:最適解はやっぱりHooks
ちなみに、上の例がやっぱりまだ何だかつらいと思った方もいるかもしれません。それは実際その通りです。そもそもthis.load("data1", loadData1);というAPIがすでにつらいです。そして、もっとマシにする方法はあります。そう、Hooksを使えばいいのです。このような例では、Hooksの「ロジックをまとめられる」という特性が有効に働きます。
上のコンポーネントをHooksで書き直すとこんな感じです。非常に分かりやすくやつ書きやすい、それでいてロジックがちゃんと共通化されている完璧な解ですね。
function useLoadedData<T>() {
const [state, setState] = useState<MaybeLoading<T>>({ isLoading: true });
const setData = (loader: ()=> Promise<T>) => {
loader().then(data => {
setState({
isLoading: false,
data,
})
})
}
return [state, setData] as const;
}
const BigComponent = ()=> {
const [data1, setData1] = useLoadedData<Data1>()
const [data2, setData2] = useLoadedData<Data2>()
useEffect(()=> {
// data1とdata2の読み込みを行う
setData1(loadData1);
setData2(loadData2);
}, [])
// ...
}
ここにたどり着くにあたっては、MaybeLoading<T>などの型を定義したり積極的に型引数として利用したりする必要があります。きれいなコードを書くためなら多少ややこしい型でも臆せず使っていくという考え方がこれを可能にしているのです。上の例の場合、型の多少難しい部分が全部useLoadedDataの中に収まっているのもポイントが高いです。実際、useLoadDataを使う側は簡潔な記述になっています。関数というのは、コードのロジックを閉じ込めるだけでなく型の複雑さを閉じ込めるのにも使えるのです。
まとめ
この記事では、いくつかのReactコード例を通して型ファーストなTypeSciptコーディングの考え方を紹介しました。この記事でお伝えしたことをまとめ直すと次の通りです。
- ロジックをなるべく型に反映させることで、TypeScriptにロジックを理解してもらう。
- TypeScriptが理解できるような書き方をする。
- Hooksはいいぞ。
最後にちょっと宣伝しようと思いますが、この記事はTypeScript wayにあまり慣れていない人を対象読者にしていることもあり、TypeScriptガチ勢には物足りなかったかもしれません(TypeScriptはやばい型システムを持つことで知られていますが、この記事では余談を除けばせいぜいunion型とkeyof型くらいしか活用していません)。React Hooksが偉すぎてそういうやばい型の出番が減っているという側面もあり、この記事で紹介したくらいの機能が使えれば大抵はいい感じにTypeScript wayでReactが書けるということでもあります。
もっとガチなTypeScriptの話がしたい方にはこのあたりの記事がおすすめです。