The hidden power of Jest matchers
素晴らしい記事。これらの「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
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の配列を数珠繋ぎすることになりました。結構、正解な気がする。
tsconfig.jsonでstrict: trueとするのは時期尚早だった
できるだけ厳しめ環境で書いた方がよろしかろうと、TypeScriptのトランスパイラオプションを最もstrictにしましたが、私の現時点でtsconfig.jsonで、strict: trueとするのは時期尚早でした。
- tsconfig.jsonで、strict: trueとする
- webpack.config.*.tsで、'path'と'webpack'をimportしているところで型定義が無いとエラーがでる
- yarn add @types/webpack -Dとして型定義を追加する。dependenciesで@types/nodeが入る
- @types/webpack-envと@types/nodeで、Moduleの定義がぶつかり、死ぬ。
- @types/webpack-envは、HMRでmodule.hotを呼ぶために必要。
- @types/webpackを抜く。yarn remove @types/webpack
- ここでワークアラウンド二択
- tsconfig.jsonから、strict: trueを抜く、もしくは
- webpack.config.*.tsを、TypeScriptやめて普通のJavaScript(.js)にする。
当面の結論
@types/nodeと@types/webpack-envの激突の根本はグローバルスコープの競合のため。この解決は当事者間の折り合いを待つか、より根本解決として将来のTypeScriptでグローバルスコープの素敵ソリューションが提供されるのを期待するかというとこかなと。ワークアラウンドとして、webpack.configをJavaScriptにします。美学だけで、効能だけ考えれば無理にTypeScriptで書く必要性なかった。そして、yarn remove ts-node。tsconfig.jsonのstrict: trueはがんばって残す。
よってこの記事のタイトルは、正しくは「webpackの設定をTypeScriptにするのは時期尚早だった」。
Redux DevToolsの問題
ChromeのRedux DevToolsも、ワークアラウンド。yarn add redux-devtools-extension。
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'; import { pushMiddleware } from './pushMiddleware'; export default function (history: History) { const store = createStore( combineReducers({ router, app }), composeWithDevTools( applyMiddleware( routerMiddleware(history), pushMiddleware(), ), ), ); if (module.hot) { module.hot.accept('./AppReducer.ts', () => { store.replaceReducer(combineReducers({ router, app: require<{app: typeof app}>('./AppReducer').app }, )); }); } return store; }
型情報がしっかり提供されていて驚いた。
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
上記記事をたまたま読んで目にした呪文。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にすれば一番厳しい。