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の話がしたい方にはこのあたりの記事がおすすめです。