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
素晴らしい記事。これらの「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版もあった。