TypeScriptでReactのライブラリを作りました

TypeScriptの勉強が嵩じて、一個、Reactのフォーム画面を作るときのめんどくさいところをまとめた小さなライブラリを作りました。

github.com

何がめんどくさいと思っていたかというと、

  • フォームの一時的状態をどこに持つか悩む。Reduxストアに持つのは大げさじゃないかなあ
  • Reactでフォームを便利にする系ライブラリは、UIコンポーネントをがっつり提供することが多い。でもこれやられちゃうとそのライブラリに対応したUIコンポーネントしか使えない。愛するmaterial-uiがつかえないじゃないか、というのも。必要なのはUIコンポーネントvalueプロパティを設定して、onChangeをフックするだけなのに
  • テスト書く時に、書きやすい作りは、Reactのプロパティの仕組みだけでやってるものかな。古いライブラリだとMixin、ちょっと前だとContextを使うのだけど、たぶんHOCでユーティリティをぶっ込んだ方がシンプルになると思った
  • Reduxは当然つかう。だからReduxのストアから値をもってきて初期化、UIローカルに値を作ってから、最後にSubmitでDispatchできるようにする
  • Submitはもちろん、値のバリデーションも同期だけでなく、非同期対応も必要

この辺、Reactの標準のState & Propertyの仕組みに閉じたアーキテクチャで、非同期はPromiseにだけ対応と決め打ちで作ったらライブラリ本体は結構すぐできました。HOCでやってるのでEnzymeではshallowでレンダリングできず、mountしないとならないのがちょっと美しく無い。

ところで問題は、手元のアプリケーションの中に埋め込みでライブラリとなるクラスおよび関数を作るところではなく、その後ライブラリに切り出すところにありました。以下のトライ&エラーで週末どっぷりでしたよ。

  • そもそもNodeモジュールを作ったことがなかった
  • TypeScriptの型定義ファイルの作り方がわからなかった -> angularのソースから気がつき解決!
  • Githubに公開するのがはじめてなのでドキドキした
  • やってみると、gitコマンド操作が怪しい

まだ作りも緩いのでnpmには登録してません。TypeScriptも初めて間も無くなので、まだまだ怪しくうさんくさい。もし試してみてくださる奇特な方がいらっしゃれば、以下のコマンドで。やってませんがes5にコンパイルしてあるのでES5以上で使えるはず。この辺もまだまだ怪しい。

$ yarn add https://github.com/masataka-kurihara/react-form-enhancer.git

今、devブランチでちょいちょい仕上げ作業をしているので、終わってmasterにマージできたらnpm登録もトライしてみたい。

in keyof T

TypeScript 2.1 · TypeScript

このTypeScript2.1で追加されたという keyof演算子をうまくつかうと、オブジェクトのプロパティを縛るのに有用でした。

test ('power of keyof', () => {
    // 返値のin keyofという受け方が今回の話題。今回Nは捨て型だけど、使ってT[N]とか可能。
    function checkObject<T>(props: T): { [N in keyof T]?: string } {
        return Object.keys(props).reduce(
            (prior, key) => {
                // 値をとるためにstringのキー&anyの値セットを持つものとしてasで受ける
                const value = (props as { [key: string]: any })[key];
                if (typeof value === 'number' && value > 0) {
                    return prior;
                }
                return { ...prior, [key]: `${key} is invalid.` };
            },
            {},
        );
    }
    type Target = {
        first: string,
        second: number,
        third: number,
        fourth: boolean,
    };
    const target: Target = { first: '', second: 10, third: -3, fourth: true };
    const result = checkObject<Target>(target);
    expect(result.first).toBe('first is invalid.'); // <- この"first"でIDEの補完が効く!
    expect(result.second).toBeUndefined(); // 以下、resultのプロパティ名がTargetのそれと同じ
    // result.fifth; // <- これはコンパイルエラー!素敵。
});

props: Tに対して、props[key]と直接できないのは、便宜上 (props as { [key: string]: any })[key]とas演算で逃げておいて、その後の戻値については、 { [N in keyof T]?: string }なので、プロパティ名は関数呼び出しの時に決めたTのプロパティ名と同じになり、IDEでも補完されるし、もちろん違う名前を使ってるとコンパイルエラー。素敵。

関数オーバーロード

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も設定しないと遅くなる。ちょっとそれは解せないが、該当箇所を書いたり消したり試すと如実に違いました。