こんにちは! Hanakla(Twitter: @hanak1a_)です! TypeScriptアドベントカレンダー 15日目となるこの記事では、
react-reduxのconnect
関数を自前実装して型定義テクニックを学んでいきたいと思います。 みんなもつよつよ型定義を書いてつよつよなライブラリを作っていこう!
みなさん、""react-redux""
してますか〜!? してますよね!
じゃあreact-redux
のAPIも知ってますか〜!? お世話になってる、なるほど!!
じゃあじゃあ、その型定義を読んだことがある人は〜!? ある!!?じゃあこの記事読まなくていいな! 家に帰ってサンタさんからの請求を部屋の隅でガタガタ震えながら待っていてくれ!ホーホーホー
connect(mapStateToProps, mapDispatchToProps)(Component)
みんな大好きですね、最近はuseSelector
の繁栄によって書く機会は少ないとおもいますが、100回は書いた人も多いんじゃないでしょうか。 connect
の型定義については正確には100行くらいの型定義になってしまうので、上記のインターフェースの型定義について解説してみます。(実際の.d.tsの定義に基づいた解説ではありません、自前型定義をします。)
connect()
は第一引数にstateから状態を取り出す関数、第二引数にpropsに割り当てるactionを指定する関数を渡し、コンポーネントをラップする関数を書きます。ここまでをざっくり書いてみましょう
declare function connect<StateProps, DispatchProps>(
mapStateToProps: (state: unknown) => StateProps,
mapDispatchToProps: () => DispatchProps,
): ConnectedComponentFactory<StateProps & DispatchProps>
まずconnect
に2つのジェネリック型を受け付けています。この型は指定されていませんが、以下の型から推論されます。
-
StateProps
はmapStateToProps
の返り値型 -
DispatchProps
はmapDispatchToProps
の返り値型
VSCode上で確認すると、正しく推論されていることがわかります。
では次にConnectedComponentFactory
(ラップしたコンポーネントを返す関数)の型を書いてみます。 そしてこちらに既に調理されたコードがあります!
import React from 'react'
type ConnectedComponentFactory<ConnectedProps> =
<P extends ConnectedProps>(
Component: React.ComponentType<P>,
) => React.ComponentType<P>;
ConnectedProps
には、mapStateToProps
, mapDispatchToProps
の返り値を複合した型が入ってきます。
次に<P extends ConnectedProps>(Component: React.ComponentType<P>) => ...
のジェネリック型がありますが、これは受け取ったコンポーネントのpropsの型をP
に受け付けます。
declare const factory: ConnectedComponentFactory
const Component = (props: { hello: string }) => null
factory(Component) // `P`の型は`{ hello: string }`になる
なぜここで
(Component: React.ComponentType<any>) => ...
を使わないかというと、Propsの型が必要になるためです。もしReact.ComponentType<any>
を受け付けてしまうとそこから推論されるPropsの型はConnectedProps
のみになってしまいます。 ジェネリクスでextends なんとか
とすることでより具象的な型を得ることが出来ます。ライブラリの型定義ではありがちなテクニックです。
declare function some(str: string); some('hello') // => strはstring型 declare function some<T extends string>(str: T); some('hello') // => strは'hello'型
最後にReact.ComponentType<P>
でConnetedProps
と、元々受け取っているProps(P
)を合成して全てのPropsの型が出来上がります。(P extends ConnectedProps
があるのでP
はConnectedProps
を内包しています)
ここまでのまとめです
import React from 'react'
type ConnectedComponentFactory<ConnectedProps> =
<P extends ConnectedProps>(
Component: React.ComponentType<P>,
) => React.ComponentType<P>;
declare function connect<StateProps, DispatchProps>(
mapStateToProps: (state: unknown) => MapStateToProps,
mapDispatchToProps: () => DispatchProps,
): ConnectedComponentFactory<StateProps & DispatchProps>
まだ続きます。こうして生成されたReact.ComponentType<StateProps & DispatchProps & Props>
型ですが、conenct
関数はmapStateToProps
,mapDispatchToProps
で渡されたPropsを自動的にコンポーネントに与えるため、このコンポーネントの利用側から見える必要がありません。
しかし、全てのPropsの型が「外側から与えられる必要のあるもの」として定義されてしまっています。これでは型がオオカミ少年ですね。なので、connectされたPropsを外部から見えないようにします。
そのためにはConnectedComponentFactory
の返り値のPropsの型からConnectedProps
のキーを取り除きます。
type ConnectedComponentFactory<ConnectedProps> =
<P extends ConnectedProps>(
Component: React.ComponentType<P>,
) => React.ComponentType<Pick<P, Exclude<keyof P, keyof ConnectedProps>>>;
返り値の型に注目してください。以下のような流れでPからConnectedPropsのキーを除外しています。
-
Exclude<keyof P, keyof ConnectedProps>
P
のキーからConnectedProps
のキーを除外する。 これでコンポーネントが本来必要としているPropsが残る(仮にOwnPropsKeys
と呼びましょう) -
Pick<P, OwnPropsKeys>
Pの内OwnPropsKeysのキーを持つプロパティだけを抽出する。(これをOwnProps
と呼びましょう) -
React.ComponentType<OwnProps>
必要だったプロパティだけが残ったコンポーネント型が生成される。
ではこれでいくつかのコンポーネントを実際に用意してテストしてみましょう。
- Valid → すべての型定義が一致している。connectされるpropsをcreateElementで省いてもエラーはない。
- Missing →
state
propが欠けている。ちゃんとConnectedComponentFactory
の引数に型エラーが出ている。 実はdispatch
の型定義が間違っているが、これは(本家の型定義でも)エラーになっていない。 - Invalid →
own
の型がnumberになっている(正しくはstring)。ちゃんとエラーになっている。
このような感じで型定義のチェックも通っています。正しそうですね!
Missing#dispatch
の返り値型が一致していないのですが、これはTypeScriptのジェネリクス型の推論が上手く通らず握りつぶされてしまいます。具体的には以下の箇所で発生しているはずです。
type ConnectedComponentFactory<ConnectedProps> =
<P extends ConnectedProps>(
P extends ConnectedProps
によって型の制約がついているはずなのですが、実際のP
の型が優先されてしまうため、キーさえ一致していればP側の型が採用されてしまいます。ここらへんは @na-o-ys さんの「なぜ TypeScript の型システムが健全性を諦めているか」という記事の話と被ってそうなそれ以前の話そうな感じですね…
(公式の型定義でも同様の問題があるのでmapDispatchToProps
はTypeScriptにおいては現状危険です。今日日this.props.dispatch
やuseDispatch
があるので使う理由がありません。コンポーネントの読み通しも難しくなるので使うのをやめましょう。)
最後に
元気があったらもうちょっと色々な型定義の話をしてみんなを(健全な領域の)型パズル沼に嵌めたかったのですが、体力が切れたのでここまでです。
最後に、Twitterでのフォローお願いします! 普段FleurというFluxフレームワークを作っていて、TypeScript周りの話やフロントエンドの設計周りの話で悩んでいることが多いので、みなさんの設計へのこだわりとかを雑にリプライで教えていただけるとFleurの設計の改善に役立ちます!よろしくお願いします
最後まで読んで頂きありがとうございました! 明日は @isy さんによるテストの話です! お楽しみに!
(React畑の方は僕が書いた React Hooks Advent Calendar 2019 11日目
の記事 エーッ!HooksでcomponentDidUpdate出来ないんですか!!? もぜひ読んでみて下さい!)