Posted at

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

これは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でこの機能が、「コンストラクタを合成する関数の結果の推論」まで強化されたので、こちらも面白いサンプルあれば。