関数オーバーロード

Functions · TypeScript

上記URLの最後、関数オーバーロード(記事のOverloads項目)なる言語仕様について。まずは以下のようなのがあったとします。

// .tsxファイル まずはこんなのがあったとします。
import * as React from 'react';

type P = { message: string };
type PP = { greeting: string };

function hoc(Comp: React.ComponentType<Partial<P>>): React.ComponentClass<PP> {
    return class extends React.Component<PP> {
        render () {
            return <Comp message={this.props.greeting} />;
        }
    };
}

たった一箇所のJSXタグのために.tsxにするが、それもまたよし。しかしなんか美しくない気がしてReact.createElementを使って書き直したかったけどはじめは理屈がわからなかったので難しかった。言語仕様を勉強してからcreateElementを見直し、簡単に例えると以下のようになっていたことがわかりました。

test('Function Overload', () => {
    function hoge(arg: number): number; // 後方の緩い関数に型の縛りをつけたエリアス、その1
    function hoge(arg: string): string; // 後方の緩い関数に型の縛りをつけたエリアス、その2
    function hoge(arg: any): any { // <- 関数の実体。anyで受けてanyで返す。でも型が緩くてイヤ
        if (typeof arg === 'number') {
            return arg * arg;
        } else if (typeof arg === 'string') {
            return arg + ', Hi!';
        }
        return false;
    }
    expect(hoge(5)).toEqual(25);
    expect(hoge('Masataka')).toBe('Masataka, Hi!');
});

hoge関数はnumberを引数にとるとnumberを、stringを引数にとるとstringを返す関数です。同じようにcreateElementは、StatelessComponentを引数にとるとSFCElement、ComponentClassを引数にとるとCElementを返してました。JavaScriptだとプリミティブ型だけで実装型がないから気にせずなんでもできるけど、TypeScriptではそのままだと型が緩くなってイヤ。なので関数の実体は一つだけど、エリアスをつけて型の縛りだけやっときましょうというもの。

React.ComponentTypeという型が、React.StatelessComponent | React.ComponentClassというユニオン型で、一度引数に渡されてきてこの型になっちゃったら、React.ComponentClassとReact.StatelessComponentのどちらの単体にもそのままでは再キャストすることはできない。

// .tsファイル
import {
    Component, ComponentClass, StatelessComponent, ComponentType, createElement,
} from 'react';

type P = { message: string };
type PP = { greeting: string };

function hoc(Comp: ComponentType<Partial<P>>): ComponentClass<PP> {
    return class extends Component<PP> {
        render () {
            const props = { message: this.props.greeting };
            // ComponentClassかStatelessComponentのどちらかにas演算
            // 関数オーバーロードされているし、実行時はTypeScript型はなくなるのでOK
            return createElement<P>(Comp as ComponentClass<P>, props);
        }
    };
}

as演算は型キャストとちがって型チェックエラーが発生しないので役立ちます。他の実装系での同様機能は型があわない場合はコンパイラエラーだったりnullに変換されたりしますが、TypeScriptは実行時にはECMAScriptにトランスパイルされて実装型がなくなるので余計なことは起きない。ただそこにある値の正しい扱いか間違いなのか関係なくコードが書けるだけ。操作によって実行時エラーが起きるか起きないかは実装内容次第。

  • 引数によって戻値内容が変化する関数をTypeScriptで型表現するには関数オーバーロード
  • ユニオン型は、その構成要素の互換性のない単体型それぞれにキャストすることができない
  • ユニオン型もしくはanyで引数を受けたら、内部ではas演算で型をあわせると辻褄があい、実行時はそもそも無問題

結論

ほかにもユニオン型で書いて怒られても理由がわからず、結局anyに緩くしちゃってたところの解決方法が副作用として理解できました。なるほどユニオン型を構成要素にはキャストできないのか。わかってみるとそりゃそうですね、だからユニオン型で書くのだし。見よう見まねでasをよくわからず書いてたところも、その意味が理解できた。型定義ファイルで大量に同じ名前のものが並んでるのも納得できる。

TypeScriptのユニオン型と関数オーバーロードとas演算子をセットで理解したら、一段上がってかなり目が開けたような気がします。理解できない型チェックエラーが劇的に減りました。

prop-typesか、それに代わる何かが必要な件

これまでReactコンポーネントを書く時、コンポーネントの使われ方を縛るためにprop-typesを用いて動的なチェックをしていました。TypeScriptでは代わって静的な型チェックを利用してprop-typesを使わずとも利用方法を縛るということになってます。しかし、けど漏れる。

import * as React from 'react';
import * as Redux from 'redux';
import * as ReactRedux from 'react-redux';
import * as Enzyme from 'enzyme';

test('HOC prop-types', () => {
    type Props = { greeting: string }; // greetingは?にしてない必須プロパティ
    const Target: React.StatelessComponent<Props> = (props) => {
        return <div className="target">{props.greeting}</div>;
    };
    const Connected = ReactRedux.connect(
        state => state,
        dispatch => ({}),
    )(Target);
    // モックReducerをjest.fn()で作る
    const reducer = jest.fn().mockReturnValue({ /* greeting: 'hello' */ });
    const store = Redux.createStore(reducer, {});
    // HOCのため、Enzymeのmountでフルレンダリング
    const rendered = Enzyme.mount(
        <ReactRedux.Provider store={store}>
            <Connected />
        </ReactRedux.Provider>);
    expect(rendered.find('.target').text()).toBe('hello'); // 上のコメント外さないと不合格
});

上記のJestテストはコンパイルできるけど合格しません。コメントを取り払ってモックReducerがgreetingプロパティを返すようにするとテストが通ります。React.Componentのように自分で緩めちゃうのは前提が違うとして、react-reduxのconnectなどHigh Order Componentの子として使う時にはコンポーネントの定義をこのTargetコンポーネントのようにガチガチにしていても素で漏れます。

prop-typesも開発ビルド時にコンソールに警告を流してくれるだけですが、それでも静かにすまされるより良い。

また、TypeScriptで書いたモジュールコンポーネントを非TypeScript環境から使う場合には当然ゆるゆる。さてどうしたものでしょうか。prop-typesを二重に書くかと思ったけど、prop-typesの型定義が用意されていないみたい。まったく必要ないと思われているのかな?もしくは賢い、それに代わる何かがあるのか?Javaだとaptでやるような、TypeScriptコンパイラに差し込むプラグインが作れれば型情報から自動でプロテクトコードを挿入したりできるんでしょうけどね。

github.com

と、こんなのググったら出てきた。。。結構まどろっこしいな。でもio-tsね。。。

やっぱりあるんですか

Using the Compiler API · Microsoft/TypeScript Wiki · GitHub

あるよ、やっぱりASTとれるか。TypeScriptコンパイラがTypeScriptで書かれているってことだからなあ。

prop-typesを無理やり使えるかな?

https://github.com/facebook/prop-types/blob/8abb8d73c6365aac2b9f508a9c49b0934de78f0a/factoryWithTypeCheckers.js#L211

この関数を作ったクラスのコンストラクタとかで呼べばいいのじゃないだろうかと思って、以下のように書いたら怒られた。直接呼ぶんじゃなく、PropTypes. checkPropTypes()を呼べという。。。

import * as PropTypes from 'prop-types';

PropTypes.object.isRequired(props, 'yourName', 'TargetComponent');
// -> 警告される。動かない。

https://github.com/facebook/prop-types/blob/master/README.md#proptypescheckproptypes

じゃあやってみようかと思っても、@types/prop-typesの型定義ファイルに、checkPropTypesが無かった。

import * as PropTypes from 'prop-types';

// あるはずのcheckPropTypesの型を定義
declare module 'prop-types' {
    function checkPropTypes<T>(
        typeSpecs: PropTypes.ValidationMap<T>,
        values: T,
        location: string,
        componentName: string,
        getStack?: () => string,
    ): void;
}

// TypeScript用のプロパティ定義
type Props = { yourName: string };

// PropTypes用のプロパティ定義
const YourTypes = { yourName: PropTypes.string.isRequired };

// テスト対象のプロパティ
const props = { yourName: 'masataka_k' };

PropTypes.checkPropTypes<Props>(YourTypes, props, 'props', 'TargetComponent');

これで完璧!。PropTypes用のプロパティ定義をするのであれば、もっと簡単にECMAScriotで書く時と同様に、static propTypes = YourTypes; とだけで通常運転ですが、動的にチェックするプロパティ定義を組み立てることもできるのでこのテクニックは有用に思います。

Interfaceのメンバ競合の問題

import * as ReduxActions from 'redux-actions';
import * as Redux from 'redux';

// 型の競合エラーがでる
interface ExAction extends Redux.Action, ReduxActions.Action<string> {}

現実感の無い実験のためのこのコードは、今日時点では残念ながらコンパイラを通りません。理由はInterfaceのメンバが競合しているからです。

// reduxのindex.d.tsより
export interface Action {
    type: any; // ここでtypeプロパティ宣言
}

// @types/redux-actionsのindex.d.tsより
export interface BaseAction {
    type: string; // ここでもtypeプロパティ宣言
}
export interface Action<Payload> extends BaseAction {
    payload?: Payload;
    error?: boolean;
}

どちらもtypeプロパティを持ってるけど、型がreduxのはanyで、@types/redux-actionsはstring。これが双方同じ型かどちらかのtypeプロパティ定義がなければ問題にならない。。reduxの方がanyで個別ならOKだからTypeScriptの将来バージョンでは通るようになるかもしれないけど、今の所はダメ。前記事の「The hidden power of Jest matchers」とメタで似たような問題ながら、TypeScriptのほうでは融通きかない。

調べるきっかけとなった私のコードは次のReduxのMiddlewareファクトリです。

import { Middleware, Action } from 'redux';
import { push } from 'react-router-redux';

type MoveToPayload = { title: string, vol: string, page: number };

export function moveToMiddleware(): Middleware {
    // redux-actions仕様(=Flux Standard Actions)をDuck Typing
    interface FSA<Payload> extends Action {
        payload?: Payload;
    }
    // reduxのActionとredux-actionsのAction<Payload>に互換性がないからこじあける
    return ({ dispatch }) => next => <A extends FSA<MoveToPayload>>(action: A) => {
        // redux-actionsのAction<Payload>だとnextに渡せない
        next(action);
        if (action.type === 'MOVE_TO' && action.payload) {
            // reduxのActionだとpayloadが受けられない
            const { payload: { title, vol, page } } = action;
            dispatch(push(`/book/${title}/${vol}/${page}`));
        }
        return action;
    };
}

Javaと違ってTypeScriptは型があってもDuck Typingなんで外形が整ってれば同じ型とみなしてくれるから、型をあわせにいきます。型をあわせるためのinterfaceは関数スコープで宣言してみました。通るし、動くけどねえ。こういうものなのかな?まだまだTypeScriptの言語仕様に不案内なので解決方法が怪しい。

The hidden power of Jest matchers

medium.com

素晴らしい記事。これらの「The hidden power」を知らず、expect(rec.prop).toBe(val)の組み合わせをプロパティの数だけ並べてましたよ。

function fn() {
    return { a: 1, b: true, c: 'hello', d: { d1: 2, d2: false }, e: 3, f: 4 };
}

// 今まで書いてたやり方。検査するプロパティの数だけMatcherを並べる。
test('an usual power', () => {
    const rec = fn();
    expect(rec.b).toBeTruthy();
    expect(rec.c).toBe('hello');
    expect(rec.d.d2).toBeFalsy();
});

// 上記のテストを、書き換えると以下。
test('The hidden power', () => {
    expect(fn()).toEqual(expect.objectContaining(
        { b: true, c: 'hello', d: { d1: expect.any(Number), d2: false } },
    ));
});

Reducerのテストでは、Stateの持つプロパティ数がとても多く一度に検査したいプロパティもたくさんあるケースがあって、これがとても有用。記事で説明されている、expect.any()とexpect.objectContaining()を組み合わせてオブジェクトの比較がすごく直感的に書けるようになりました。

トランスパイルに時間がかかってたのでtsconfig.jsonを直した

いつからか、TypeScriptトランスパイルに極端に時間がかかるようになってました。ビルドも、DevServer実行も、テストもすべてトランスパイルが走るのでこれが遅いと開発の勝手全てに影響します。特にtslintが遅くなった結果、エディタで警告やエラー箇所を修正しても、なかなかマーカーが消えないので、ストレスにもなります。

なんでだろうと見直してみると、いつのまにかtsconfig.jsonからignore設定が消えてました。書いた記憶があるけど消した記憶はないのですが、なんでだろう。

{
    "compilerOptions": {
        "strict": true,
        "module": "commonjs",
        "target": "es5",
        "jsx": "react",
        "lib": ["es6", "dom"],
        "types": ["webpack-env", "jest"]
    },
    "include": ["ts/**/*"],
    "exclude": ["node_modules"]
}

node_modulesをexcludeで対象外にすると、元の素早さに戻りました。同時にincludeも設定しないと遅くなる。ちょっとそれは解せないが、該当箇所を書いたり消したり試すと如実に違いました。

不慣れによる失敗、配列の宣言

まだTypeScriptが手に馴染むには書いた量が少なく、日々失敗が多い。

type Book = { title: string, vols: [string] };
type BooksPayload = { books: [Book] }; // 正解は、= { books: Book[ ] };

こんなコードを書いていた。意図としてはBookの配列をActionのpayloadに搭載したいために書いてるのだけど、その型宣言を[Book]と書いていました。また、Book.volsの[string]も間違いです。ここの正解はbooks: Book[ ](およびvols: string[ ])と書くこと。間違ったままだとこれは「一つだけBook型の要素を持つTuple型」の表記になっています。恐ろしいことに大概のケースでトランスパイルもできてしまうのですが、引っかかるのは以下の場合。

const books: Books = []; // エラー。never[]と[Book]は型が違うと怒られる

空の配列が代入できない。また、複数の要素を持っても当然NG。Flowの時にTupleがあるんだなーと流し見してたけどTypeScriptにもTupleがあるとは。Tupleは(もう何年も書いてないけど)Swiftを書くとAPIの戻値で頻出だった記憶がある。

再発防止策

配列の型宣言を、string[ ]とかとせずに、Arrayと徹底したほうが良いのかな?しかしtslint-config-airbnbが配列リテラルで書けって怒る。

Reducerを書くのにhandleAction"s"をやめた

redux-actionsを用いてActionCreatorとReducerを書いていますが、TypeScriptに移行して書き方がちょっと変わりました。

これまではActionCreatorをまとめて作るcreateActionsと、Reducerをまとめて作るhandleReducersを便利に用いてました。しかしTypeScriptに移行してみるとこのまとめた書き方は型情報をうまく活かせない。

// @types/redux-actionsのindex.d.tsより
export function handleActions<State, Payload>( // <- 型引数PayloadがreducerMap内で共通化
    reducerMap: ReducerMap<State, Payload>,
    initialState: State
): Reducer<State, Payload>;

ActionCreatorもReducerも、まとめるとどうしてもPayloadの型をアプリケーションワイドで大きなものにするか、anyで受けるかになってしまいます。アプリケーションワイドにすれば注目すべき情報の粒度が大きくなるし、anyで受けたら型の恩恵をうけられない。それならhandleAction(handleAction"s"ではなく)で個別にReducerを定義したほうがよさそう。

type State = Map<string, any>;
type MoveToPayload = { compiler: string, framework: string }; // 粒度が小さくany無しで書ける

export const moveToAction = createAction<MoveToPayload, string, string>(
    'MOVE_TO',
    (compiler, framework) => ({ compiler, framework }),
);

export const moveToReducer = handleAction<State, MoveToPayload>(
    moveToAction,
    (state, action) => action.payload ? state.merge(action.payload) : state,
    Map({}),
);

そうなるとActionCreatorとReducerはセットで書いたほうが、型も共通するしテストも一体だし、見通しが良いので格段に書きやすくなった気がします。

とはいえ、一つのReducerが一つのActionしか処理しないとなるとStoreを作るときにさすがに粒度があわない。小さなアプリであれば一つ、大きなアプリでも数個の名前空間にまとめたい。どうしたものか手段を探したけどしっくり来るものが見つからなかったので、最終的にはcreateStore(combineReducer())で複数名前空間を一つにまとめる前段階として、手書きで複数のアトミックなReducerを連結することにしました。

function reduceReducers<S>(reducers: Reducer<S, any>[]) {
    // Array#reduce()で連結して一個のReducerにまとめる
    return reducers.reduce<Reducer<S, any>>(
            (previousValue, currentValue) =>
                (state, action) => currentValue(previousValue(state, action), action),
            (state, action) => state, // <- reduceに初期値を省略するTypeScript型定義がなかった
        );
}

const app = reduceReducers<State>([
    // handleActionで作ったアトミックなReducerたちを以下に並べる。
    togglePageLayout,
    toggleAdjustPage,
    requestBooks,
    receiveBooks,
    moveToReducer,
    locationChangeReducer,
]);

// そしてStoreを作る。
const store = createStore(
    combineReducers({ router, form, app }), // react-router-redux + redux-form + アプリ
    composeWithDevTools(applyMiddleware(
        routerMiddleware(history),
        moveToMiddleware(),
    )),
);

// Containerではこんな感じ。connect()はreact-reduxのもの
export default connect(state => state.app.toJS(), () => ({}))(NavigationComponent);

結果として上記のように、アトミックなReducerの配列を数珠繋ぎすることになりました。結構、正解な気がする。