15K 行のアプリを TypeScript 1.8 から 2.0 に移行してみた

先日 TypeScript の新しいメジャーバージョン 2.0 のコンパイラの beta 版がリリースされました.

コンパイラのチェックの強化や非 null 型,tagged union など,いくつかの機能が追加・強化され,自分の趣味プロジェクトでも恩恵に与れそうだったので試しに移行してみました.

移行してみたのは下記の Electron でつくり中の Twitter Client アプリ(React+Redux)で,全体で大体 15000 行ぐらいです.

github.com

下記の手順で修正してみました.

  1. コンパイラをアップデートしてビルドしてみる
  2. --noImplicitThis--noUnusedLocals, --noUnusedParameters を有効にしてみる
  3. --strictNullChecks を有効にして nullability をチェックするようにする
  4. include で glob を使う
  5. Tagged union types で Redux の Action types をリファクタリングする
  6. 引数リストに trailling comma を入れていく
  7. インターフェースやクラスメンバを readonly 指定する

各段階でビルド&単体テスト確認をするのが大事だと思うので,なるべく細かく段階を分けました.コミットも大体各作業ごとになっているので,随時 diff のリンクを貼ります.

「説明しなくて良いからコードを見せろ」という猛者向けに,下記が今回行った作業の全 diff です.+-1000行弱ぐらいですね.

diff

コンパイラをアップデートしてビルドしてみる

TypeScript 2.0 beta は下記のコマンドで入るので,サッと入れてとりあえず npm run build してみます.

$ npm install --save-dev typescript@beta

TypeScript 2.0 では処理フローを見て型を付けるようになったので,この時点でビルドが失敗することがあります. 例えば下記みたいな場合です.

type T = 'aaa' | 'bbb';

const v: T = 'aaa';

switch (v) {
    case 'aaa': /* ここでは v の型は 'aaa' */ console.log('OK'); break;
    case 'bbb': /* ここでは v の型は 'bbb' */ console.log('OK'); break;
    default:    /* ここでは v の型は never */ console.log('NOT OK', v); break;
}

このように,制御フローを見て適宜型を切り替えてくれるようになりました. 上記のように v の型として取りうる可能性のある型がなくなると never という型になり,v を参照しようとするとエラーになります. never type も 2.0 で導入された機能です.

基本的に煩雑さが減る方向のはずなので,修正は容易なはずです.

diff

--noImplicitThis--noUnusedLocals, --noUnusedParameters を有効にしてみる

2.0 ではいくつかのチェック機能がコンパイラに追加されました.互換性が理由(?)でデフォルトはオフのようですが,特に理由が無ければ有効にしておいたほうが良さそうです. tsconfig.json"noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true のように指定できます.

機能名の通り各チェックを行って,検知した時はコンパイルエラーに落としてくれます.特に noUnusedLocals は tslint で検知してくれなかった未使用の import を検出してくれて助かりました.

diff

ここまではたとえ 2.0 にすぐに上げられないプロジェクトでも一時的にコンパイラのバージョンを上げてチェックさせることで恩恵に与れそうです.

--strictNullChecks を有効にして nullability をチェックするようにする

2.0 では null の型がそのまま null となり,T | null のように書くことで nullable な型を表現できます.また,undefined についても同様に T | undefined のように書けます. さらに tsconfig.json"strictNullChecks": true を追加することで,デフォルトで全ての型を non nullable type として扱うようにできます.

基本的には

  1. null を代入しているのに nullable でない変数の型に | null を追加する
  2. null が来る可能性があるのにチェックしていない箇所は素直にチェックする処理を入れる
  3. 型は nullable だけど実質 null な値が来ないところには null assertion 演算子(後置 !)を使う

という処理を入れていけば修正は OK です.3. は例えば下記のような感じです.

// 対象の要素が無い時 getElementById は null を返すので戻り値型は本来
// Element | null だが,静的に HTML に id が振られているので実質失敗しない
// expr! で expr が null にならないとコンパイラに伝える
const elem = document.getElementById('root')!;

また,null assertion 演算子undefined にも使えます.

変更箇所もなかなか多かったのですが,他にも細々とうまくいかないところがあり,この変更は思ったより大変でした.

例えば,strictNullChecks によってオプショナル引数の型が以前と変わります.

function foo(optional?: number) {
    // 1.8 まで -> number
    // 2.0 から -> number | undefined
    optional;
}

これだけ見ると問題ないように見えますが,型定義ファイルには,実質オプショナルでないのにオプショナル引数になっている定義が割といっぱいあります. 特にコールバックの引数の指定がまずいものが多く,1.8 までは型的には変わらなかったので問題にならなかったのですが 2.0 では問題になります.

今回例えば困ったのは react.d.tsStatelessComponent です.

interface StatelessComponent<Props> {
    (props?: Props, context?: any): ReactElement<any>;
    // ... その他雑多なプロパティ
}

上記の props? が問題です.

const MyComponent: React.StatelessComponent<Props> = props => ...;

今までは上記のようにコンポーネントの型を指定し,引数を推論させていてこれでうまくいっていました.ですが,引数は props? となぜかオプショナルに定義されているため, 2.0 からは props の型は Props | undefined に推論され,propsundefined でないかどうかのチェックを入れる必要が出てきてしまいます. (僕の知識が足りないだけで実は props?context? が実際に undefined になるようなケースがひょっとしたらあるのかもしれないですが…もしそうなら教えていただけるとうれしいです)

仕方がないので下記のようにしてワークアラウンドを入れています.

const MyComponent = (props: Props) => ...;

これだと引数の型はプログラマが指定したものになるので undefined チェックを回避できます.現状ではこれで問題になっていませんが,上記型定義の「その他雑多なプロパティ」がほしくなった場合は困ることになります.

また,react-redux.d.tsconnect() も困りました.connect()mapDispatchToProps(第2引数)だけを指定したいときは第1引数を null にできるのですが,第1引数の型定義に | null が入っていないため null を渡せません.仕方ないので手元で react-redux.d.ts に手を入れ,リポジトリに含めて typeings でインストールしたものの代わりにそちらを見るようにして凌いでいます.

型定義ファイルの更新状況などを見ながら,strictNullChecks を付けるのは少し待ったほうが良いかもしれません.DefinitelyTyped では今のところ TypeScript 2.0 向けの型定義ファイルの開発を types-2.0 ブランチで進めているようです.

include で glob を使う

TypeScript のプロジェクトファイル tsconfig.json で指定するソースコード一覧には今まで glob が使えなかったためファイルを1つずつ追加していました. 2.0 からは晴れて *** が使えるようになったのでそれを使うようにします.

**/*.ts**/*.tsxtypings/*.d.ts のように指定すれば良くなりました.

diff

Tagged union types で Redux の Action types をリファクタリングする

2.0 では tagged union types という機能が入り,union types のどの型の値が入っているのかを,各型に共通のプロパティで判断してくれます. プロパティには string literal type を指定すれば良いようです.公式の What's New に載っている例が分かりやすいです.

flux や redux では type プロパティを見てアクションの種類を判断するため,アクションの型を tagged union type にすることでうまく型がつくようになります.

/*
 * action_type.ts
 */

// 'type' プロパティがタグになる
type Action = {
    type: 'AddSomething',
    item: Something,
} | {
    type: 'Sort',
    kind: Kind,
} | {
    type: 'DeleteLast',
};
export default Action;


/*
 * actions.ts
 */
import Action from './action_type';

export function addSomething(item: SomeThing): Action {
    return {
        type: 'AddSomething',
        item,
    };
}

export function sort(kind: Kind): Action {
    return {
        type: 'Sort',
        kind,
    };
}

export function deleteLast(): Action {
    return {
        type: 'DeleteLast',
    };
}


/*
 * reducer.ts
 */
import Action from './action_type';

function reduce(state: State = DefaultState, action: Action) {
    switch (action.type) {
        case 'AddSomething':
            // ここでは action の型は {type: 'AddSomething', item: Something} になる
            const next = ...;
            return next;
        case 'Sort':
            // ここでは action の型は {type: 'Sort', kind: Kind} になる
            const next = ...;
            return next;
        case 'DeleteLast':
            // ここでは action の型は {type: 'DeleteLast'} になる
            const next = ...;
            return next;
        default:
            return state;
    }
}

大体こんな感じです.これで各アクションの処理内で間違ったプロパティにアクセスしていた時などは全てコンパイルエラーになります. 文字列リテラルが複数箇所にあるのが気になるかもしれませんが,全て string literal 型としてコンパイラがチェックするのでタイポしていてもコンパイルエラーになり安心です. 良い感じです.

ちなみに以前は type プロパティが取る値を全部 Kind というオブジェクトに入れて Kind.AddSomething のように参照していたので,:%s/Kind\.\(\w\+\)/'\1'/g のように置換して少しマクロを使ってやるだけで かなり簡単に置き換えられました.Vim 便利.

diff

引数リストに trailling comma を入れていく

個人的にかなり嬉しい機能なのですが,2.0 からは関数の引数に trailing comma が許されるようになりました(公式の例). (配列やオブジェクトの要素は前から trailing comma を許してます).

tslint がチェックしてくれるようになるのを待っても良いのですが,せっかくなので手で置き換えました.\h\w*\s*\(\n あたりで適当に grep で引っ掛けて修正します. Vim なら一度行末にコンマを追加する修正を入れれば,他の箇所は . で繰り返すだけで OK ですね.Vim 便利.

diff

インターフェースやクラスメンバを readonly 指定する

変更するつもりのないインターフェースのプロパティやクラスメンバに 2.0 から導入された readonly 指定をつけてまわります.基本的に大抵のプロパティは変更されないと思うので,デフォルトで readonly 付けるぐらいの勢いで良さそうです.:%s/^\s*\zs\ze\w\+?\=:/readonly /g とかやると雑にプロパティに readonly を付けられるので,付けてみてコンパイラに怒られたらそのフィールドを外す感じで機械的に作業できます.Vim 便利.

diff

まとめ

最近ブログ書いてなかった(ネタはあったのに)ので,久々に人柱になった内容をまとめてみました.まだ beta ではありますが,今のところ変なバグは踏んでません.--strictNullChecks 以外はとりあえずやっておいて損は無さそうです.せっかくなので,いずれ 2.0 が正式リリースされた時に役立つと良いなぁと思っています.