導入への3ステップ
EightではTypeScriptに精通したメンバーは少なく、ましてや大規模なサービスにTypeScriptを導入した経験のある人はいませんでした。そこで、PC版EightのWebフロントエンドへ導入する前に、3つのステップに分けて入念に準備しました。
ステップ1:ドキュメント・書籍・勉強会の資料などで学ぶ
まずはTypeScriptの言語仕様やその周りのフレームワークとの連携を学ぶため、書籍などを用いた社内勉強会を行い、理解を深めることから始めました。また、公式サイトをはじめとするTypeScriptについてまとめられたドキュメントや社外の勉強会の資料にも目を通しました。
書籍では、『実践TypeScript』(マイナビ出版)や『プログラミングTypeScript』(オライリー)を、ドキュメントでは、TypeScript公式サイト・TypeScript Deep Dive 日本語版・非破壊TypeScriptを参考にしました。どれもカバーしている範囲や詳細度が異なるため、複数の情報源を参考にすることが重要です。
ステップ2:ゼロからTypeScriptで開発する
先の学習のステップが終わり、TypeScriptでの開発の準備ができました。そこで、社内の小さなリポジトリでTypeScriptを用いた開発の経験を積むことにしました。
ちょうどその頃、Eightでは社内向けのReactベースのコンポーネントライブラリを新規に作成する機会があり、開発言語としてTypeScriptを採用しました。ここでは、実際にとりうる値を型として常に意識する開発を経験し、TypeScriptの書き味や有効性を実感することができました。
ステップ3:既存のJavaScript製ライブラリをTypeScriptに移行する
新規リポジトリでTypeScriptを書くことは経験できたので、次にJavaScriptとTypeScriptを共存させつつの移行を試すことにしました。社内向けのNode.js用ライブラリがJavaScriptで書かれており、ファイル数が十数個程度だったので、これを題材に選びました。
このライブラリのビルド設定をTypeScriptがコンパイルできる形に変更し、ファイルごとに移行を進め、最終的にすべてのファイルをTypeScript化しました。ここでは、JavaScriptからTypeScriptへ型を付けながら移行していく際、少しずつ型の精度を上げていく方法を学びました。
導入と移行に向けた作業
事前準備である程度、導入や開発のイメージが固まったため、実際にEightのWebフロントエンドにTypeScriptを導入していきます。
導入・移行方針の検討
まず、導入やその後の移行の方針についてチームで検討しました。コード品質の担保や開発効率向上のための作業で障害を起こしては元も子もないため、既存のコードに影響を与えないようにすることを導入の方針として決めました。そこで、ファイルの拡張子からTypeScriptのファイルを識別して処理を分ける方式を採用しました。これにより、既存のコードに対しては今まで通りの処理を行い、新しく作成した.ts,.tsxファイルのみ追加で事前にコンパイル処理を行います。
また、全体の開発を止めてTypeScriptへの移行にリソースを割くことが困難であったことに加え、一気にファイルを移行すると何かミスがあったときに手戻りが大きいため、ファイルごとに徐々に移行していく方針をとりました。もちろん、移行時にも既存のコードのロジックを一切変えないことを最優先に進めていきました。
導入手順
tsconfig.jsonの設定
まず最初にtsconfig.jsonの設定から始めました。tsconfigはTypeScriptのコンパイラ(tsc)の各種設定などを行うことができるファイルです。ここで実際にTypeScriptがどうコンパイルされ、どんなコードになるかを決めていきます。VSCodeはリポジトリ内のtsconfigを元にエラーの表示などを行います。TypeScriptをインストールし、tsc --initで元となるファイルを作成します。いくつか設定を変更し、最終的に以下のような形になりました。(一部省略)
{
"compilerOptions": {
"target": "ESNEXT", /* ESNEXTのコードを出力 */
"module": "ESNEXT", /* import, exportを使ったコードを出力 */
"allowJs": true, /* JavaScriptファイルもコンパイル対象に */
"checkJs": false, /* JavaScriptファイルの型チェックは行わない */
"jsx": "preserve", /* JSXの記法は変換せずそのままに */
"strict": true, /* 型チェックなどは厳し目に設定 */
"baseUrl": "./src", /* import時に起点となるディレクトリを指定 */
"paths": {
"*": ["*"] /* baseUrlからの相対パスによるimportを認識可能に */
},
...
},
"include": ["./src"]
}
TypeScriptをコンパイルするとESNEXTのJavaScriptを出力するように設定しました。また、"allowJs": trueでJavaScriptファイルをコンパイル対象に含め、JavaScriptファイルから推論される型もTypeScriptで利用できるようにしました。型チェックなどは後から厳しくすることが難しいと考え、"strict": trueに設定しています。
webpackを使うときの設定
Eightのフロントエンドではビルドにwebpackを使っているため、TypeScriptのコンパイルの実現に業界内で採用事例の多かった以下の2つの方針を検討しました。
- babel-loader経由でBabel(+ @babel/preset-typescript)を使う
- ts-loader経由でtscを使う
EightではJavaScriptファイルにおけるESNEXTからES5への変換やReactのJSXの変換のために既にbabel-loaderを使っていました。
今回はJavaScriptとBabel周りの設定を共通にし、TypeScriptファイルでのみ追加の処理を加えるために、ts-loaderを採用することにしました。
拡張子で識別し、TypeScriptファイルの場合は最初にts-loaderを通すことでJavaScriptファイルにコンパイルし、そのあとはbabel-loaderで他のJavaScriptファイルと共通の変換処理を行います。Babelの設定は変更しないので、既存のJavaScriptファイルに影響がない形で導入ができます。tsconfigで"allowJs": true に設定していましたが、実際にコンパイルされるのはTypeScriptファイルのみになります。
また、型チェックを伴うts-loaderでのコンパイルは時間がかかるため、transpileOnly: trueに設定して高速化を図っています。同時に、別プロセスで型チェックを行うFork TS Checker Webpack Pluginを使うことで、開発中もしっかり型チェックを行うことができます。開発中の型チェックには多少メモリ容量を必要としますが、加えた変更によってどのファイルが影響を受けたかを即座に知ることで開発を加速させることができます。
ちなみに、実はこの時点で自動テストの設定も完了しています。EightではMochaというテストフレームワークを使っており、webpackを用いてテストに必要なファイルをあらかじめコンパイルしてくれるmochapackというツールと併用しています。これにより、先に挙げたwebpackの設定で、テストファイルについてもTypeScriptを解決できるようになっているというわけです。よって、TypeScriptファイルをテスト対象とすることと、テストファイル自体をTypeScriptで書くことが同時に可能になりました。ここがスムーズに実現したのは、ありがたかったです。また、テストフレームワークとしてはJestも有名ですが、その場合には@babel/preset-typescriptやts-jestを使うことでTypeScriptへの対応が可能です。
ESLintを使うときの設定
次にTypeScriptファイルのLint(静的解析)の設定を行いました。TSLintは非推奨なので、既にJavaScriptファイルのLintに使用していたESLintを使うことにしました。ESLintをTypeScriptファイルにも適用するために、@typescript-eslintを使用しました。
また、EightではコードフォーマッタのPrettierとESLintを併用しているため、eslint-config-prettierを用いてPrettierのフォーマットと競合するESLintのルールを無効化しています。そのため、TypeScriptでも無効にする設定をESLintの設定ファイルに追記しました。
そして、拡張子でTypeScriptファイルかを判別し、overridesで既存のESLintの設定を上書きするようにしました。
CI
CI上でTypeScriptの型チェックを行うために、設定ファイルを更新しました。型チェックにはtsc --noEmitコマンドを使用しました。このコマンドは、コンパイル処理を行いつつもファイルを出力しないため、型チェックのみを行いたい時に使えます。実際の作業としては、Circle CIのjobに上記のコマンドを実行するstepを追加するのみです。これで、PR(Pull Request)がマージされる際は、型エラーがない状態を担保できます。
これまでで、TypeScriptファイルをコンパイルすることが可能になりました。リポジトリにいくつかTypeScriptファイルを追加し、数人のエディタ上でコード補完や、型エラー、Lintエラー、コードの自動整形などがうまく動いていることを確認しました。そして、PRを出してCIでの型チェックがうまくいっているかを確認し、最後に開発環境へのデプロイがうまくいくかを確認しました。問題がなかったため、一旦ここでリリースを行いました。本番環境でのデプロイにも問題なく、しっかりTypeScriptファイルがコンパイルされていることが確認できて一安心しました。
JavaScriptファイルからの移行手順
ここまででTypeScriptの導入はひとまず完了しましたが、今回は既存のJavaScriptファイルからTypeScriptファイルに移行していく部分についても触れておこうと思います。先の方針で挙げた通り、ロジックを一切変えないことを最優先としました。
1. ファイルの拡張子を変更する
まずファイルの拡張子を変更し、tsc --noEmit --watchなどで型エラーが出ている部分を確認します。
2. ライブラリの型定義をインストールする
型定義がないライブラリはimportしている部分でエラーが出るため、DefinitelyTypedで型定義を探してインストールしました。それでも足りない場合は型定義ファイル(.d.ts)を作成しました。
3. わかる範囲で型を書く
はじめから厳密にすべての型を書くのは難しく、既存のコードがそもそも型を付けづらい形になっている場合も多いのですが、時間の許す範囲で頑張って型を書いていきました。
4. 残った型エラーを潰す
最後に残った型エラーを潰していきます。型がわからなかった場合にひとまず付ける型として、Todoという型を用意しました。
declare type Todo = any;
ある程度移行が完了した時点で、あとから修正するためにanyに別名をつけています。私たちの場合は既存のプロジェクトからの移行で完全にanyを消すことは難しいと考えており、このような方針をとっています。一方で、完全にanyを消せるのであればいらないと思います。 unknownという型もありますが、エラーを潰すことに関してはそこまで有用ではないため、移行時には使える場所だけ使う形にしました。
また、エラーを潰す際には// @ts-expect-errorコメントも使用しました。これは、次の行にエラーがあることを保証するコメントで、エラーが消えた場合はtscが必要ない旨を通知してくれます。例えば、JavaScriptからimportした関数のエラーを潰す場合に// @ts-expect-errorコメントを使うと、そのJavaScriptファイルをTypeScriptに移行した際にエラーがなくなる場合があるため、有用です。
ちなみに似たようなコメントとして// @ts-ignoreコメントがありますが、これは次の行にエラーがあってもなくても無視するため、必要ない場合に気付くことができる// @ts-expect-errorコメントを使用しました。
5. TypeScriptの範囲を広げていく
上記の手順1~4を繰り返し、PRを出し続けていきます。通信などのデータ構造に関わる部分やよく使われる関数などから行うと、型の恩恵が得られやすいです。根気のいる作業なので、チームでしっかりとやっていくという合意を事前にとることをおすすめします。
ここまで述べてきた方針で作業を進めていき、問題は起きませんでした。導入時にはビルドやCIの設定などさまざまな場所に⼿を加えたため、デプロイやリリース周りでトラブルが起きないかと⼼配していましたが、うまくいったことでひと安⼼しました。また、リリースしてからJavaScriptファイルをTypeScriptへ移⾏することも1年以上行ってきましたが、こちらも問題は起きていません。これは、既存のコードやロジックに影響を与えないという⽅針が正しかったという証明でもあります。
