7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

TypeScriptAdvent Calendar 2019

Day 15

react-reduxのconnect()から学ぶTypeScriptの型定義テクニック

Last updated at Posted at 2019-12-15

こんにちは! Hanakla(Twitter: @hanak1a_)です! TypeScriptアドベントカレンダー 15日目となるこの記事では、
react-reduxのconnect関数を自前実装して型定義テクニックを学んでいきたいと思います。 みんなもつよつよ型定義を書いてつよつよなライブラリを作っていこう!

みなさん、""react-redux""してますか〜!? してますよね!
じゃあreact-reduxのAPIも知ってますか〜!? お世話になってる、なるほど!!
じゃあじゃあ、その型定義を読んだことがある人は〜!? ある!!?じゃあこの記事読まなくていいな! 家に帰ってサンタさんからの請求を部屋の隅でガタガタ震えながら待っていてくれ!ホーホーホー:santa::money_with_wings: :money_with_wings: :money_with_wings:

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つのジェネリック型を受け付けています。この型は指定されていませんが、以下の型から推論されます。

  • StatePropsmapStateToPropsの返り値型
  • DispatchPropsmapDispatchToPropsの返り値型

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があるのでPConnectedPropsを内包しています)

ここまでのまとめです

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>
    必要だったプロパティだけが残ったコンポーネント型が生成される。

ではこれでいくつかのコンポーネントを実際に用意してテストしてみましょう。

image.png

  • 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.dispatchuseDispatchがあるので使う理由がありません。コンポーネントの読み通しも難しくなるので使うのをやめましょう。)

最後に

元気があったらもうちょっと色々な型定義の話をしてみんなを(健全な領域の)型パズル沼に嵌めたかったのですが、体力が切れたのでここまでです。

最後に、Twitterでのフォローお願いします! 普段FleurというFluxフレームワークを作っていて、TypeScript周りの話やフロントエンドの設計周りの話で悩んでいることが多いので、みなさんの設計へのこだわりとかを雑にリプライで教えていただけるとFleurの設計の改善に役立ちます!よろしくお願いします :bow:

最後まで読んで頂きありがとうございました! 明日は @isy さんによるテストの話です! お楽しみに!

(React畑の方は僕が書いた React Hooks Advent Calendar 2019 11日目の記事 エーッ!HooksでcomponentDidUpdate出来ないんですか!!? もぜひ読んでみて下さい!)

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?