Help us understand the problem. What is going on with this article?

recomposeで見るTypeScriptの高階関数型推論

More than 1 year has passed since last update.

recomposeで見るTypeScriptの高階関数型推論

by Quramy
1 / 11

これはgotanda.js#11 用のLT資料です。


何の変哲もない React + ReduxをTypeScriptで書いた場合のコード

import React, { ComponentType } from "react";
import { Action, Dispatch, combineReducers, createStore } from "redux";
import { connect, Provide } from "react-redux";
import { render } from "react-dom";

// component
type Props = {
  count: number,
  onIncrement: () => void,
  onDecrement: () => void,
  someOuterProp: string,
};
const MyComponent: ComponentType<Props> = ({ someOuterProp, count, onIncrement, onDecrement }) => (
  <div>
    count: {count}
    <button onClick={onIncrement}>+1</button>
    <button onClick={onDecrement}>-1</button>
  </div>
);

type IncrementAction = { type: "INCREMENT" };
type DecrementAction = { type: "DECREMENT" };
type AppActions = IncrementAction | DecrementAction;

const count = (state: number = 0, action: AppActions) => {
  switch (action.type) {
    default:
      return state;
    case "INCREMENT":
      return state + 1;
    case "DECREMENT":
      return state - 1;
  }
};

const reducer = combineReducers({ count });

type AppState = ReturnType<typeof reducer>;

const MyApp = connect(
  ({ count }: AppState) => ({ count }),
  (dispatch) => ({
    onIncrement: dispatch({ type: "INCREMENT" }),
    onDecrement: dispatch({ type: "DECREMENT" }),
  }),
)(MyComponent);

const store = createStore(
  combineReducers({ count }),
);

render(
  <Provide store={store}>
    <MyApp someOuterProp="gotanda" />
  </Provide>
, document.getElementById("app"));

比較的最近(2.8以降くらい)のTypeScriptであれば、必要最小限の型アノテーションだけでreduxのアプリが書けるようになってる。

type IncrementAction = { type: "INCREMENT" };
type DecrementAction = { type: "DECREMENT" };
type AppActions = IncrementAction | DecrementAction;

const count = (state: number = 0, action: AppActions) => {
  switch (action.type) {
    default:
      return state;
    case "INCREMENT":
      return state + 1;
    case "DECREMENT":
      return state - 1;
  }
};

const reducer = combineReducers({ count });

type AppState = ReturnType<typeof reducer>; // => { count: number }

ところでrecomposeって:

  • https://github.com/acdlite/recompose
  • ReactにおけるHigher Order Component(HOC)関連のユーティリティライブラリ
  • React hooksの登場と共にメンテ終了のお知らせ

HOCはReact Componentを引数にとって、React Componentを返す関数のこと。

import { ComponentType } from "react";
type HOC<P1, P2> = (c: ComponentType<P1>) => ComponentType<P2>;

react-reduxのconnect(...)もHOC。

const MyApp = connect(
  ({ count }: AppState) => ({ count }),
  (dispatch) => ({
    onIncrement: dispatch({ type: "INCREMENT" }),
    onDecrement: dispatch({ type: "DECREMENT" }),
  }),
)(MyComponent);

recompose自体もwithStateやらlifecycleやら色々なHOCを提供してくれるけど、そこは割愛。


複数のHOCを組み合わせるときは、compose で合成する

const enhancer = compose(hoc1, hoc2); // = (c) => hoc1(hoc2(c))
const EnhancedComponent = enhancer(BaseComponent);

たとえば、{ hoge: string } という値をpropsに注入するようなHOC withHoge があったとすると、

declare function withHoge<P extends { hoge?: string }>(c: ComponentType<P>): ComponentType<Omit<P, "hoge">>;

下記のように使う。実際、react-routerのwithRouterとかDIチックな動きをする系統のHOCはconnectと合わせて、container component側でcompositionするケースが多いと思う。

const enhancer = compose(
  connect(
    ({ count }: AppState) => ({ count }),
    (dispatch) => ({
      onIncrement: dispatch({ type: "INCREMENT" }),
      onDecrement: dispatch({ type: "DECREMENT" }),
    }),
  ),
  withHoge,
);

const MyApp = enhancer(MyComponent);

ようやく今日の本題。3.3以前のTypeScriptでは、compose で合成されたHOCからはpropsの型情報が抜け落ちてしまう問題があった。

composeする際に、自分でHOCのinとoutのpropsを再度アノテートする必要があった、という意味。これは辛いぜ。

const enhancer = compose<{
  count: number,
  onIncrement: () => void,
  onDecrement: () => void,
  hoge: string,
  someOuterProp: string,
}, {
  someOuterProp: string,
}>(
  connect(
    ({ count }: AppState) => ({ count }),
    (dispatch) => ({
      onIncrement: dispatch({ type: "INCREMENT" }),
      onDecrement: dispatch({ type: "DECREMENT" }),
    }),
  ),
  withHoge,
);

TypeScriptの3.4で Higher order function type inference という推論強化が行われたことによって、下記のような汎関数の合成が行えるようになった。

type HOC<P1, P2> = (c: ComponentType<P1>) => ComponentType<P2>;

function compose<P1, P2, P3>(hoc12: HOC<P1, P2>, hoc23: HOC<P2, P3>): HOC<P1, P3>;

3.3までは、compose で作った戻り値の汎関数における P1P3 がともに { } に落ちてしまっていた。

ジェネリクスを含む汎関数を返すような関数について、戻り値の型推論にてジェネリクスが保存されるようになった ということ。


これでようやくアノテート無しでcomposeできるようになる!

const enhancer = compose(
  connect(
    ({ count }: AppState) => ({ count }),
    (dispatch) => ({
      onIncrement: dispatch({ type: "INCREMENT" }),
      onDecrement: dispatch({ type: "DECREMENT" }),
    }),
  ),
  withHoge,
);

const MyApp = enhancer(MyComponent);

、、、と言うには実はまだ早くて、DefinitelyTypedみたらrecomposeのd.tsの型定義が総称型使うようになってなかった :innocent:


  • Higher order function type inferenceで汎関数合成がはかどるようになったよ
    • 実際のところ、この機能で恩恵受けるライブラリとかを色々考えてみたけども、「ジェネリクスが含まれる汎関数を返すような関数」の実例が、ReactのHOCとrecomposeくらいしか思い浮かばなかった。。。
    • なんか面白いサンプルあったら教えてください :pray:
  • 余談ですが、TypeScript 3.5でこの機能が、「コンストラクタを合成する関数の結果の推論」まで強化されたので、こちらも面白いサンプルあれば。
Quramy
Front-end web developer. TypeScript, Angular and Vim, weapon of choice.
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away