Reduxでの有用なTypeScriptサンプル

https://github.com/reactjs/redux/tree/master/test/typescript

Reduxのリポジトリの中、test/typescriptにReduxでの有用なTypeScriptサンプルがあります。強い型付けでのMiddlewareの書き方とか参考になるもの多い。また、これらのテストは動かさずにコードをコンパイラやLintにかけることでindex.d.tsをテストするという。こういうテストもあるのだなあ。

Deep Dive

Introduction · TypeScript Deep Dive

一冊まるごとオンラインで。著者の人は何かに困ってググると、各掲示板でたくさんの人に答えてるすごい人。PDF版もあった。

型強化の呪文。noImplicitAny

www.infoq.com

上記記事をたまたま読んで目にした呪文。noImplicitAny。

Over time, they reached the point of enabling advanced compiler options, such as –noImplicitAny to prevent the compiler from inferring an any type.

advancedってすごいな。なるほど。やってみましょう。また、ドキュメントを見ると、strictNullChecksなるものも。

// tsconfig.json
{
    "compilerOptions": {
        "noImplicitAny": true,
        "strictNullChecks": true,
        "module": "commonjs",
        "target": "es5",
        "jsx": "react",
        "lib": ["es6", "dom"],
        "types": ["webpack-env", "jest"]
    }
}

ちょいちょい「暗黙のany」を怒るエラーがでてきました。たとえばこんなの。

export class Counter extends React.Component<{}, { counter: number }> {
    constructor(props) {
        super(props);
        this.state = { counter: 0 };
    }
// 省略
}

このconstructorの引数に型が無い。直すと以下。

export class Counter extends React.Component<{}, { counter: number }> {
    constructor(props: {}) {
        super(props);
        this.state = { counter: 0 };
    }
// 省略
}

結構な箇所を直したら、noImplicitAny=trueでもOKになった。明示的なanyはOK。

import { handleActions } from 'redux-actions';
import { LOCATION_CHANGE } from 'react-router-redux';
import { matchPath } from 'react-router';
import { Map } from 'immutable';

const decodeName = (pathname: string) => {
    const m = matchPath<{ name?: string; }>(pathname, { path: '/player/:name' });
    if (m) {
        return { ...m.params, name: m.params.name };
    }
    return {};
};

type AppState = Map<string, any>;

export const app = handleActions<AppState, any>(
    {
// 省略
        MOVE_TO: (state: AppState, { payload: { name } }) => {
            return state.merge({ name });
        },
        [LOCATION_CHANGE]: (state: AppState, { payload: { pathname } }) =>
            state.merge(decodeName(pathname)),
    },
    Map({}),
);

Redux ActionsのhandleActionsの型引数の2番目には、Payloadがきます。でもこれはMOVE_TOのときには、{ name } だし、LOCATION_CHANGEのときは{ pathname }だから、anyで受けたかった。で、明示しているからOKだった。

React RouterのmatchPathは面白くて、型引数にExpressスタイルのURL分解パラメータをあらかじめ型として持つ。'/player/:name'の結果を{ name?: string; }で受けたために、m.params.nameという取り方ができるようになる。

strictNullChecksは結構直すの簡単ながら、なるほどそこでnullの可能性あるかと気がつかしてくれる。とても有用。

結論

noImplicitAnyはtrueにするのが正義。strictNullChecksはさらにもっと実利がある。ほか、noImplicitThisやalwaysStrictも含めて、単にstrict=trueにすれば一番厳しい。

TypeScript 2.3 · TypeScript

TypeScriptでもリリースビルドする

TypeScriptの8つめ。

Productionをやります。

// webpack.config.production.ts
import * as path from 'path';
import * as webpack from 'webpack';

export default {
    entry: './ts/index.tsx',
    output: {
        path: path.resolve('static/js'),
        filename: 'bundle.js',
    },
    module: {
        rules: [{
            test: /\.(ts|tsx)$/,
            use: [
                'awesome-typescript-loader',
                'tslint-loader',
            ],
        }],
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js'],
    },
    plugins: [
        new webpack.optimize.UglifyJsPlugin({ sourceMap: false, comments: false }),
    ],
};

特に論点ないですね。

$ yarn add rimraf -D

rimrafをいつも使ってます。

// package.jsonの一部
    "scripts": {
        "build": "rimraf ./static/js/*; webpack -p --config webpack.config.production.ts"
    },

yarn run buildもショートハンドがあります。これはnpmと同じ。

$ yarn build

Atomを入れてTypeScriptを書く

TypeScriptの7つめ。

愛用のWebStormでTypeScriptを研究しつつ、おまけでAtomインスコしてサンプルを作るときの清書プラットフォームとして使って見ました。

Homebrew-Cask

私はHomebrewでNodeもYarnも入れてますので、Atomも。MacOS向けGUIインストーラーアプリをHomebrewでいれる拡張として、このHomebrew Caskを使います。

$ brew tap caskroom/cask
$ brew cask install atom

プラグインをガシガシいれます。入れたのは以下のものと、それらが依存するもの。

  • atom-typescript
  • linter-tslint
  • platformio-ide-terminal
  • file-icons

他にはちょこちょこ入れ出ししてみたけど、良いものが見つからず、上のでほぼいい感じ。

課題

「Go to Declaration」で型定義に飛んだりすることができなかった。調べるとバグなんだか、そうじゃないんだか。結構長い間苦労されているのかな?Atomでまだ解決できてない。

しかし、WebStormはザクザク型定義で飛んだり跳ねたりできる!すげーや商用(なのかな?)。

$ brew cask uninstall atom
$ rm -rf ~/.atom

一旦、Atomはアンインストール。また時機をみて試してみます。おそらくWebStormのサブスクリプションが切れるころ。

TypeScriptでもReduxのHMR対応しておきましょう

TypeScriptの6つめ。

HMRには3段階あって、非Reactアプリ、Reactアプリと続いて最後にReact+Reduxアプリに到達します。

// /store/Store.ts
import { createStore, combineReducers, applyMiddleware } from 'redux';
import { routerReducer as router, routerMiddleware } from 'react-router-redux';
import { History } from 'history';
import { composeWithDevTools } from 'redux-devtools-extension';

import { app } from './AppReducer';

export default (history: History) => {
    const store = createStore(
        combineReducers({ router, app }),
        composeWithDevTools(
            applyMiddleware(
                routerMiddleware(history),
            ),
        ),
    );
    if (module.hot) {
        module.hot.accept('./AppReducer.ts', () => {
            const nextReducer = { router, app: require<{app: typeof app}>('./AppReducer').app };
            store.replaceReducer(combineReducers(nextReducer));
        });
    }
    return store;
};

もう派生論点なんでフルにコードは示さずとも、ポイントだけ。requireを使って更新Reducerを別名で読み込んで、store.replaceReducerを呼ぶことです。

上記コードではChromeプラグインのRedux DevToolsに対応させるため、react-router-reduxを入れました。

TypeScriptでもHMRを実現する

TypeScriptの5つめ。まだまだ続きます。

$ yarn add react-hot-loader@next
$ yarn add @types/webpack-env -D

react-hot-loaderは3.0.0-beta.7が入りました。そして@types/webpack-envを!これが自分的には一番ヤバい。見つけるのに苦労した。

// webpack.config.development.ts
import * as path from 'path';
import * as webpack from 'webpack';

export default {
    entry: [
        'react-hot-loader/patch',
        './ts/index.tsx',
    ],
    output: {
        filename: 'bundle.js',
        publicPath: '/js/',
    },
    devtool: 'inline-source-map',
    module: {
        rules: [{
            test: /\.(ts|tsx)$/,
            use: [
                'react-hot-loader/webpack',
                'awesome-typescript-loader',
                'tslint-loader',
            ],
        }],
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js'],
    },
    plugins: [
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NamedModulesPlugin(),
        new webpack.NoEmitOnErrorsPlugin(),
    ],
    devServer: {
        host: 'localhost',
        port: 9000,
        hot: true,
        historyApiFallback: true,
        contentBase: path.resolve('static'),
    },
};

いろいろ出し入れしてみて、一番シンプルな作りがこれかなあ。historyApiFallbackは今は必要ないですが、React RouterすなわちHistoryを用いてかつProxyでワークアラウンドしないアプリを作るときに必須です。

  • entryにreact-hot-loader/patchを入れる
  • ローダーチェーンの先頭にreact-hot-loader/webpackを入れる
  • HotModuleReplacementPluginを登録する
  • devServer.hotをtrueにする
// tsconfig.json
{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es5",
        "jsx": "react",
        "lib": ["es6", "dom"],
        "types": ["webpack-env", "jest"]
    }
}

ここで、webpack-envが出てくる!これが無いとHMRの定石である、module.hotの名前解決ができない。いやーグローバル名前空間でいろいろやれるのはダメだと思いますが、JavaScript黒歴史としてこんなのが山ほどこれからも出てくることでしょう。TypeScriptはJavaScriptとそのまま地続きだからグローバルで自由なユーザー定義を禁止するような言語仕様にできなかったか。。。

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

import { Hello } from './Hello';
import { Counter } from './Counter';

export const App = () => (
    <div>
        <Hello compiler="TypeScript" framework="React" />
        <Counter />
    </div>
);



// /index.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { AppContainer } from 'react-hot-loader';

import { App } from './components/App';

const render = () => ReactDOM.render(
    <AppContainer>
        <App />
    </AppContainer>,
    document.getElementById('application'),
);

render();

if (module.hot) {
    module.hot.accept('./components/App', () => {
        const NextApp = require<{App: typeof App}>('./components/App').App;
        ReactDOM.render(
            <AppContainer><NextApp /></AppContainer>,
            document.getElementById('application'),
        );
    });
}

Hotにする対象として、App.tsxに受け直しておきます。これをmodule.hot.acceptを用いて監視するのですが、ES6でやったときのようにWebpack2以降のモジュール読み込みシステムなるものは使えない。tsconfig.jsonのほうでモジュールシステムをcommonjsにしているのでこうなったかなと推測してます。かといってcommonjs以外の選択肢はTypeScriptのコンパイラオプションのドキュメント読んでいろいろ研究して見たけどどうもぴったりの解がほかになかったです。

requireを使ってNextAppと別名で読み込み直すのはコードもトリッキーな印象のものになります。型を解決するために、require<{App: typeof App}>なんて書き方してますからね。もしAppをexport defaultしていると、require<default: typeof App}>(‘./components/App’).default; なんてことになってエディタによっては「defaultは予約語だ!」というようにLintで引っかかったりします。汚いが、しょうがない。いまのところ他にやり方が思いつかない。

これで、TypeScriptでReactのHMRもできました!

TypeScriptでもJest+Enzyme

TypeScriptの4つめ。

Lintができたらテストもちゃんとやっとく。最近覚えたJestそしてEnzymeでやります。

$ yarn add jest enzyme react-test-renderer ts-jest @types/jest -D

jestとenzymeを加える他に、@types/jestが必須です。必須ではないですが@types/enzymeも加えておいたほうが今後にはより良いですがとりあえず加えなくても動きます。react-test-rendererはenzymeのpeerDependenciesなため。

// package.jsonの一部
    "scripts": {
        "test": "jest"
    },
    "jest": {
        "moduleFileExtensions": ["ts", "tsx", "js"],
        "transform": {
            "^.+\\.(ts|tsx)$": "./node_modules/ts-jest/preprocessor.js"
        },
        "testMatch": [
            "**/__tests__/*Test.(ts|tsx)"
        ]
    },

package.jsonにscripts.testを追加するのとjestエントリを書きます。jest.moduleFileExtensionsはtsとtsxに加えて、たとえJSでテストを書かずともjsを加えなければなりません。これも裏で処理パイプの中で.jsが出てきてそれらを解決できなくなるのを避けるためです。webpackのresolveと同様な感じ。

transformerでts-jestを設定しています。TypeScriptファイルのトランスパイルを行うのを下記URLのように自分でコード書くのもOKなようですが、ts-jsonはより重厚なことをしています。

https://github.com/facebook/jest/blob/master/examples/typescript/preprocessor.js

// tsconfig.json
{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es5",
        "jsx": "react",
        "lib": ["es6", "dom"],
        "types": ["jest"]
    }
}

@types/jestを入れ、tsconfig.jsonのcompilerOptions.typesにその型情報を読み込む設定をしないと、Jestテスト中で書くdescribeとtestの両グローバル関数が名前解決できません。またcompilerOptions.libにes6とdomを追加します。これはTypeScriptのコンパイラオプションのページ、

https://www.typescriptlang.org/docs/handbook/compiler-options.html

ここの、–libスイッチの説明にある、

  • Note: If –lib is not specified a default library is injected. The default library injected is:
    • For –target ES5: DOM,ES5,ScriptHost
    • For –target ES6: DOM,ES6,DOM.Iterable,ScriptHost

これがぶつかります。ブラウザで動かすからES5をターゲットとしてるため、Nodeで動かすJestテストやLint実行では問題が起きるということ。前にBabel環境でHMRをやるときにも似たようなことがありましたね(似て非なるのですが)。Babelではモジュール機能をON/OFFにするために面倒なことがありましたが、こちらはトランスパイル時にリンク(?)する標準モジュールの選択でということ。

// /components/__tests__/HelloTest.tsx
import * as React from 'react';
import { shallow } from 'enzyme';
import { Hello } from '../Hello';

describe('HelloComponent', () => {
    test('<Hello TypeScript>', () => {
        const hello = shallow(<Hello compiler="TypeScript" framework="React" />);
        expect(hello.find('h1').text()).toBe('Hello from TypeScript and React!');
    });

    test('<Hello JavaScript>', () => {
        const hello = shallow(<Hello framework="React" />);
        expect(hello.find('h1').text()).toBe('Hello from JavaScript and React!');
    });
});

テストはEnzymeのshallowでシンプルに書いてみました。$yarn testと、これもまたyarn run testのショートハンドが用意されています。

f:id:masataka_k:20170906080833p:plain

AtomはテストのGUIを入れてないから地味。WebStormには素敵なUIがあります。