こんにちは、21 卒エンジニアの id:d-kimuson です。
モバイルファクトリーでは、最近のプロダクトではフロントエンドに TypeScript を採用していますが、僕がアサインされているプロダクトは歴史が長く JavaScript で書かれていて、今回 TypeScript へのリプレースを行いました。
既存プロダクトの TS リプレースではしっかり型付けすることは難しいので、型チェックオプションを緩くしてリプレースすることが多いと思います。しかし、既存コードからリプレース後のコードまで全て型安全性が担保できなくなってしまうので、後からの strict
化は非常に大変になってしまいます。
今回のリプレースでは、型チェックオプションは緩くしない代わりに @ts-nocheck
や @ts-expect-error
を使用することで、段階的に型安全性を高めやすい形でリプレースを行いました。
このエントリでは TypeScript へのリプレース方針やプロセス等について説明します。
TypeScript の利点
JavaScript から TypeScript へ置き換えることで以下のような利点があります。
(1) 安全性の向上
静的解析がない状態では、null チェック漏れがないか等を開発者の意識だけで担保することになります。
テストで担保できますが、網羅的にチェックして全体の安全性を底上げできるのは TypeScript の良いところです。
置換え中にも型チェックや型を利用した ESLint ルールのおかげで、いくつも null チェックが漏れているコードや実行時エラーになる誤ったコードが見つかりました。
(2) 開発体験の向上
エディタが型を理解することで、正確な補完や hover 時に変数の中身が分かる等の強力なエディタのサポートを受けることができます。
例えば、VSCode では、TypeScript の Language Server が動いているので文字を入力すると同時に型チェックが動きます。実際に動かすよりも間違ったコードを書いた時のフィードバックループが圧倒的に早く、開発スピードがあがります。
また、型はドキュメントとして機能し、外部パッケージを含むモジュールの使い方を教えてくれます。 他人のコードを使う機会が多いほど開発体験に影響します。
(3) ソースコードの品質が高まる
JavaScript は潜在的に秩序のないコードをになりがちです。秩序のないコードでも正しい挙動さえしていればその当時は問題ないかもしれませんが、後から意図が理解しにくい・バグを生みやすい等の問題を生みます。
TypeScript は導入するだけで JavaScript に一定の秩序をもたらします。
TypeScript ではソースコードを静的に解析して型をつけるので、静的に解析することが難しいコード(≒秩序のないコード)は型エラーで怒られることになります。よってそもそも秩序の無いコードを書くこと自体が難しくなります。
あるいは、型を上書きするような手段を使うことで秩序の無いコードを書くこと自体はできますが、そこに型情報があれば秩序のないコードを読み解くヒントになるはずです。
TypeScript リプレースによってこれらの利点を教授するため、リプレースを行うことにしました。
TypeScript へのリプレースの方針
今回のリプレースでの課題と、どうやって解決したのかについて説明します。
問題 ― 巨大なコードのリプレースと向き合う
上記のような利点があるので TypeScript へリプレースしたかったのですが、プロダクトの歴史が長いこともあって「既存の JavaScript のコードベースが巨大である」という問題がありました。
TypeScript では、静的に検知できる問題が最大化していることが理想です。静的に検知できる問題が多ければ多いほど、開発時のフィードバックループが速く周るので開発スピードがあがりますし、安全性も向上します。
したがって、理想的な導入後の状態は以下のような状態です。
- 全てのソースコードに型がついている
- 硬い型チェックオプションによって型の信用度が高い
strict
オプションが有効 (暗黙の this 禁止, 厳格な null チェック, ...etc)any
禁止 (eslint の no-explict-any ルールで禁止できます)ts-ignore
やas
等の型安全性を損なう構文の使用が必要最低限になっている
しかしながら、既存の JavaScript コードベースが大きいため全てのファイルに厳格な型をつけることは現実的なリソースでは難しい状態でした。
型を書く工数もそうですが
- 元のコードが間違っている (null チェックをしていない等)
- 元のコードが静的解析に向かない(手続き的な書き換えが多いコード等)
等のケースも多くあり、これらのケースでは型情報だけではなく実装に修正を入れるリファクタリングも必要になります。型情報だけなら気軽に追加・修正が行えますが、実装に修正が入る場合はそれだけ動作チェックのリソースも必要になるのでなおさら現実的な工数で行うのは難しいです。
避けた解決の指針 ― がんばらない TypeScript
この問題への一般的な対策として、いわゆる「がんばらない TypeScript」があります。
参考: TypeScript再入門 ― 「がんばらないTypeScript」で、JavaScriptを“柔らかい”静的型付き言語に - エンジニアHub|Webエンジニアのキャリアを考える!
現実的なリソースで理想的な状態へのリプレースを一気に行うことは難しいので、型チェックのオプションを緩くすることで型チェックが通るようにしようというものです。
例えば
- 暗黙的な any を禁止する
noImplicitAny
オプションを外せば引数の型注釈が抜けていてもエラーにならなくなります - null チェック強要の
strictNullChecks
オプションを外すことで null チェックをしていないコードでエラーが出なくなります
オプションを緩くするだけでエラーの数は大きく減りますので、いくつか残った型エラーを手動で対応するだけでリプレースできます。
がんばらない TypeScript のつらいところ
型チェックオプションを緩くすることで、低コストでリプレースが行える「がんばらない TypeScript」ですが、リプレース後の状態はリプレース後の TypeScript としては辛いところも多いです。
すべてのファイルで型が信用できなくなってしまう
型チェックの強度は型の信用度に直結します。 緩い型チェックオプションの元では、型が実装と乖離する可能性が高くなります。
// 緩い strictNullChecks が off なら、以下は型エラーにならない const text = ['hello'].find(() => false) // 型は string だが値は undefined
この例では、strictNullChecks
オプションを無効にしている場合、 変数 text は string 型に解決されますが、見ての通り text には undefined が入ります。
緩い型チェックオプションでは、こういった型と実装が異なるということが起こりやすくなります。
緩い型チェックオプションの元では 新規のコードも含めて こういった型と値の乖離が起こりやすくなります。型を疑いながらコーディングをすることになりますし、型チェック自体も緩いので、本来型チェックで気付けるはずだった単純なミスも実際に動かして気づくことが増えます。
古いソースコードで、こういった乖離が起きてしまうのはある程度仕方ないでしょう。しかし、新規で書くコードでも型が信用できず、型チェックで検知できる問題が少ない状態で TypeScript を書いていくことになってしまうのは避けたいです。
後からの型安全性を高めることが難しい
全体の型チェックを緩くするということは、リプレース後に追加したコードも含めて 型安全性が低くなるということです。既存の JavaScript コードに合わせて型チェックオプションが緩くされているので、新規で書くコードも同レベルでしか型安全性を担保できません。
もちろん TypeScript を意識して書くことになるので、ある程度は意識的に安全なコードを書くことにはなると思います。しかし、開発者の意識だけで strict と同レベルの型安全なコードを書いていくのは現実的に難しいと思います。
そのまま運用していても型安全性は低いままなので、あとから型安全な状態に行こうすると、再び strict にするためのプロジェクトを計画して実行する必要があります。型チェックオプションを硬くして、プロジェクト全体のソースコードに型エラーが起きるので、それらを直していく作業が必要になります。
strict 化の事例としては以下の事例が参考になります。
https://speakerdeck.com/k2wanko/c5a64443-55b3-4863-aafa-da539a6ef623
リプレース後のコードも修正対象になり、非常に大変です。
解決の指針 ― メリハリのある TypeScript
TypeScript には、行やファイル単位で型エラーを無効にできるコメントが用意されています。
@ts-nocheck
: そのファイルの型チェックを無効にする@ts-expect-error
: 次の行の型エラーを無視する。次の行に型エラーがない場合、エラーになる
これらを使うことで「全体の型チェックオプションを緩くする代わりに、型チェックの範囲を狭めて堅い型チェックをかける」ことができます。このやり方でも同じく低いコストで巨大なコードベースを TypeScript へ移行をできます。
具体的には、ファイルの型エラーが少ない場合は @ts-expect-error
で対応して、一定より多いファイル場合は @ts-nocheck
で型エラーを無視すれば移行が完了します。
この記事では、この指針を型が堅い範囲と緩い範囲がしっかり分かれるので「メリハリのある TypeScript」と呼ぶことにします。
「メリハリのある TypeScript」では、「がんばらない TypeScript」でのつらいポイントが解消されます。
型の信用度にメリハリが付く
型チェックの強度自体は堅いので型チェックを無効にしているファイルとそうでないファイルで明確に型安全性がメリハリがつきます。
目印 | 型の信用度 | 静的解析 | 心持ち |
---|---|---|---|
@ts-nocheck |
低 | 無 | 補完が効きやすいだけの JavaScript |
@ts-expect-error |
中 | 有(型の信用度が低いので一部ではあるが検知できる) | 型は間違ってる可能性もあるから疑いつつ使う (静的に検知できる問題も多い) |
上記が存在しない | 高 | 有 | 型を全面的に信用することで恩恵を最大限受けられる |
@ts-nocheck
, @ts-expect-error
を目印に、明確に型が信頼出来る範囲か分かるので、型安全性が高いファイルでは型を信頼して TypeScript の恩恵を最大限受けることができます。
逆に、移行時に @ts-nocheck
を書いたファイルでは、静的解析が機能しませんが、型自体はついているので多少のエディタサポートは受けながら書くことができます。
型安全性は運用とともに向上していく
型チェックオプションが緩い状態では、型チェックオプションを高めるときに大きなコストが必要になりますが、こちらの指針であれば運用とともに型安全性が向上していきます。
新規で追加したコードは自動的に型安全性なコードになるので、「新規で追加したコードまで型安全性が低くなる」という問題はなくなり、型安全な割合は自動的に増えていきます。
また、既存のコードもファイルや行単位で型エラーを無効にしているだけなので、@ts-nocheck
や @ts-expect-error
のコメントを外すことで型チェックの範囲を容易に広げることができます。移行時にすべてリファクタリングするのは難しくても、機能改修等のタイミングで既存のコードを触るときならしっかりとした型付け・リファクタリングも行いやすいでしょう。
strict 化は一括で行う必要があり、コストが大きいですが、脱 @ts-nocheck
は運用しながら段階的に行うことができます。
置き換え直後こそ @ts-nocheck
の影響で、型チェックで問題を発見できない範囲が広くありますが、運用しながら徐々に型安全な範囲を広げて理想状態に近づけることができます。
これらの理由から、このプロダクトでは「メリハリのある TypeScript」の指針でリプレースを行うことにしました。
既存コードに型を付ける
移行時点では、型エラーが出る箇所はコメントで型エラーを無視することにしましたが、そのまま型エラーを一括無視するとほとんどすべてのファイルで無視することになってしまいます。したがって、ある程度型付け対応をした後に、仕上げとして型エラーが出ているファイルの型チェックを無効にしました。
ここでは、実際に型付けをしていった方法について説明します。
基本的な方針: 型エラーは ts-expect-error で握りつぶす
JS の拡張子を .ts
に変えただけの状態では、型注釈が抜けている箇所が多くあるので、まずは型注釈を書きます。
そうすると
- 静的解析に適していない
- 実装に問題がある (null チェック等が抜けている・実装ミス)
といった箇所が型エラーとして残るので、これらの型エラーを解消するのが基本的な流れです。
型エラーの解消方法は
- (1)
as
や@ts-expect-error
を使って型エラーを握りつぶす - (2)元のコードをリファクタリングして静的解析に適した・あるいは問題のないコードにする
のいずれかになりますが、このプロダクトでは(1)の方針で、@ts-expect-error
を書くことで型エラーを解消することにしました。
(2)のようにちゃんと問題ないコードにするのが理想的ですが
- フロントエンドのテストが存在しない現状では気軽に直すことができないこと
- リファクタリングを伴う型付けは後者に比べて機械的に行えず、時間的に厳しかったこと
これらの理由で、現実的なリソースでは難しかったので、リファクタリングは行いませんでした。
また、型エラーと言ってもエラー箇所が原因の問題とは限らず、依存しているモジュールの型がおかしいケースもあります。こういったケースでは、後からモジュール側の型定義を直したときに型エラーが解消されます。ts-expect-error
であれば次の行にエラーがないとエラーになるので、後から型定義が修正されたときに一括で ts-expect-error
を削除できます。
型付けの作業の流れ
「型エラーは @ts-expect-error で握りつぶす」という方針で以下のような流れで型付けを行いました。
1. コンポーネントを独自の型付け関数に通す
この記事では詳しく触れませんが、このプロダクトでは Vue の 1 系を使っている都合でコンポーネント内ではほとんどの値が any に型付けされてしまいます。また Vue2 系で廃止されているイベント通信を多用している都合もあって独自の型付け関数を定義してコンポーネントでも適切に型が付くようにしています。
まずは、型付け関数を通すことでコンポーネント内のコードにも型を付けていきます。
2. コンポーネントのエラーを消す
型付け関数を通すことで適切に型エラーが出るようになるので、上で書いたように型注釈を書きつつ、エラーを @ts-expect-error
で消していきます。
基本的には全て型エラーを握りつぶしますが、HTTP クライアント・エラーをロギングするためのモジュール等の依存モジュールの問題である場合も多かったのでそちらを先に直していきました。
型エラーの出るファイルを一括で対象外にする
残った型エラーの出るファイルは一括で @ts-nocheck
コメントを付けました。
これで型チェックを回しても一切エラーがでない状態になり、リプレースを終えることができました。
リプレースの結果
リプレース後に、機能開発等の機会がありましたが、新規で書くコードに関しては型安全性が担保されているので、快適な状態で開発できました。
また、運用しながら徐々に型が信用できる範囲を広げられるようにリプレースを行ったので、今後継続的に型が信用できる範囲を広げていけることが大事だと思っています。
型が信用できるファイルの割合は継続的に計測していて、リプレースから 2 ヶ月で型安全なファイルの割合を 27% → 46% に増やすことができました。コードの自動生成をするようになり、その分が含まれているので、既存コードに型を付けたことで向上した割合は7%程です。現在進行中のフロントエンド環境改善のタスクが完了すると57%まで向上する見込みです。
今後は、チームの TS 習熟度向上の目的も兼ねて既存のソースコードに型付けする作業をペアプロで行う予定ですので、一層型安全な範囲を広げていけることが期待できます。
まとめ
このエントリでは、プロダクトの TypeScript へのリプレースについて運用しながら型安全性を高めやすい「メリハリのある TypeScript」という方針について紹介しました。
型チェックを緩くする代わりに、 @ts-nocheck
, @ts-expect-error
のコメントで型エラーを無視することで
- 後からのやると大変な strict 化を同時に行うことができる
- 段階的に安全な範囲を広げやすい
という運用しながら型安全性を高めやすい形でリプレースを行うことができました。
今回は TS 移行時の方針として紹介しましたが、既に緩い型チェックオプションで TypeScript を利用している場合でも有効だと思います。
特にチームメンバーが TypeScript に慣れていない場合であれは、移行直後に困りにくいように「がんばらない TypeScript」で移行するのも良いと思います。
ですが、一定時間が経ってある程度 TypeScript が浸透したのであれば、ずっと緩い型チェックのまま進めるのは望ましくありません。型エラー無視のコメントを利用してでも厳格化できると良いのではないでしょうか。厳格化以降は徐々に型安全性が向上しますし、新規で書くコードでは TypeScript の恩恵を最大限享受しながら開発できます。