flow
TypeScript
React
flowtype
recompose
0

FlowtypeとTypeScriptにおけるHOC(またはrecomposeの)型付けについて

型付け地獄へようこそ

個人的にはここ2, 3年ほどJavaScriptの型付けとしてFlow(Flowtype) をメインで扱ってきていたのですが、いよいよTypeScriptもバージョン3になり型の表現力も増し、さらにBabelでもうまくいけるようになったのを見てきたので、TypeScriptもちらほらやってきています。

しかしながら、主にReactの開発でTypeScriptを使っていますが、Flowtypeと比べてどうも腑に落ちない点が多々あります。その一つがHOCの型付けの際の型推論です。

まずは普通のpresentational (dumb) component。描画要素およびハンドラをプロパティで受け取るだけ。

components/Comp.tsx
import * as React from 'react';

export type CompProps = {
  title: string,
  count: number,
  countUp: () => void,
  reset: () => void,
  onChangeTitle: (e: React.SyntheticEvent<HTMLInputElement>) => void,
};

export const Comp = ({
  title,
  count,
  countUp,
  reset,
  onChangeTitle,
}: CompProps) => (
  <div>
    <div>title: <input type="text" value={title} onChange={onChangeTitle} /></div>
    <div>count: {count}</div>
    <div>
      <button onClick={countUp}>Count Up</button>
      <button onClick={reset}>Reset</button>
    </div>
  </div>
);

で、次にこいつにstateやhandler functionをマップするcontainer (smart) componentを用意するというのがReactの開発でよくやる方法だと思うのですが、ここではrecomposeに代表されるHOCを使ってenhanceすることできれいにcomponentにpropsを与えてあげたいところです。recomposeにはさまざまなHOC関数(およびその作成関数)があたえられているので、うまくこれらを合成してやることでenhancerとなるHOC関数を作ることが可能です。もちろんReduxとかにつなげる場合はここにreact-reduxのconnectなども入ってくることになります。

しかし、TypeScriptだと、こいつが厳しい。すべての合成するHOCに対して型定義を与えてあげなければいけない。

containers/Comp.ts
import { compose, withProps, withStateHandlers, withHandlers, StateHandlerMap } from 'recompose';
import { Comp, CompProps } from '../components/Comp';

export type CompOuterProps = {
  title: string,
  onChangeTitle: (title: string) => void,
};

type CompAdditionalProps = {
  incNum: number,
};

type CompState = {
  count: number,
};

type CompStateHandlers = {
  countUp: () => void,
  reset: () => void,
};

type CompModifiedHandlers = {
  onChangeTitle: (e: React.SyntheticEvent<HTMLInputElement>) => void,
};

const enhancer = compose<CompProps, CompOuterProps>(
  withProps<CompAdditionalProps, CompOuterProps>(
    ({ title }) => ({ incNum: title.length })
  ),
  withStateHandlers<
    CompState,
    StateHandlerMap<CompState>,
    CompOuterProps & CompAdditionalProps
  >(
    { count: 0 },
    {
      countUp: ({ count }, { incNum }) => () => ({ count: count + incNum }),
      reset: () => () => ({ count: 0 }),
    }
  ),
  withHandlers<
    CompOuterProps & CompAdditionalProps & CompState & CompStateHandlers,
    CompModifiedHandlers
  >({
    onChangeTitle: ({ reset, onChangeTitle }) => (e) => {
      const title = e.currentTarget.value;
      reset();
      onChangeTitle(title);
    },
  }),
);

export default enhancer(Comp);

こいつはつらい。
何が辛いのかわからない? Flowtypeだとこんな感じにできる。

components/Comp.jsx
/* @flow */
import React from 'react';

export type CompProps = {
  title: string,
  count: number,
  countUp: () => void,
  reset: () => void,
  onChangeTitle: (e: SyntheticEvent<HTMLInputElement>) => void,
};

export const Comp = ({
  title,
  count,
  countUp,
  reset,
  onChangeTitle,
}: CompProps) => (
  <div>
    <div>title: <input type="text" value={title} onChange={onChangeTitle} /></div>
    <div>count: {count}</div>
    <div>
      <button onClick={countUp}>Count Up</button>
      <button onClick={reset}>Reset</button>
    </div>
  </div>
);
containers/Comp.js
/* @flow */
import {
  compose, withProps, withStateHandlers, withHandlers, type HOC,
} from 'recompose';
import { Comp } from '../components/Comp';

export type CompOuterProps = {
  title: string,
  onChangeTitle: (title: string) => void,
};

const enhancer: HOC<*, CompOuterProps> = compose(
  withProps(
    ({ title }) => ({ incNum: title.length })
  ),
  withStateHandlers(
    { count: 0 },
    {
      countUp: ({ count }, { incNum }) => () => ({ count: count + incNum }),
      reset: () => () => ({ count: 0 }),
    }
  ),
  withHandlers({
    onChangeTitle: ({ reset, onChangeTitle }) => (e) => {
      const title = e.currentTarget.value;
      reset();
      onChangeTitle(title);
    },
  }),
);

export default enhancer(Comp);

TypeScriptでは必要だった合成関数内のHOCで使っている中間の型定義がさっくりいなくなっている!

そしてちゃんと型推論できているのは適当にtypoでもしてみたらわかるはず。

逆に、TypeScriptの方は、composeの中のHOC functionの型定義がたとえまちがってしまっていたとしても、composeした結果は別におこられることはない。compose<CompProps, CompOuterProps>でなかったことにされてしまっている。

containers/Comp.tsx
...

type CompStateHandlers = {
  // CompPropsに定義されているのは `countup` ではなく `countUp`
  countup: () => void,
  reset: () => void,
};

type CompModifiedHandlers = {
  // この名前のhandlerはCompPropsには存在しない
  onChangeTitle2: (e: React.SyntheticEvent<HTMLInputElement>) => void,
};

const enhancer = compose<CompProps, CompOuterProps>(
  withProps<CompAdditionalProps, CompOuterProps>(
    ({ title }) => ({ incNum: title.length })
  ),
  withStateHandlers<
    CompState,
    StateHandlerMap<CompState>,
    CompOuterProps & CompAdditionalProps
  >(
    { count: 0 },
    {
      // CompPropsに定義されているのは countup ではなく countUp
      countup: ({ count }, { incNum }) => () => ({ count: count + incNum }),
      reset: () => () => ({ count: 0 }),
    }
  ),
  withHandlers<
    CompOuterProps & CompAdditionalProps & CompState & CompStateHandlers,
    CompModifiedHandlers
  >({
    // この名前のhandlerはCompPropsには存在しない
    // よってonChangeTitleの上書きは失敗し、SyntheticEventがそのまま渡されてしまう
    onChangeTitle2: ({ reset, onChangeTitle }) => (e) => {
      const title = e.currentTarget.value;
      reset();
      onChangeTitle(title);
    },
  }),
);

// これでもtypecheckは通ってしまう
export default enhancer(Comp);

要は、Flowtypeのcomposeはちゃんと合成関数のチェーンの中身の型を推論できているのに対し、TypeScriptの方はできてないという点。

もしかしたらrecomposeの型定義の問題かもしれんけど、Flowtypeは組み込みで合成関数のための$ComposeというUtility Typeがあるので、この推論ができているのでは? という気もします。その場合TypeScriptに同等のものがないとさすがにきついなー、というのが感想。きついきついいってすいません。もちろんTypeScriptでもっとクールな方法があるのを僕が知らないだけという可能性はあるのですが、いくつかプロジェクト見た感じではほとんど上記のような冗長な型定義与えていたりしたので、あーそれが普通なのかー、と思っている次第です…。