Visual Studio Code の厳密な null チェック
2019年5月23日 Matt Bierner、@mattbierner
安全性は速度を許容する
高速に動くのは楽しい。新しい機能をリリースし、ユーザーを喜ばせ、コードベースを改善するのは楽しい。しかし同時に、バグのある製品をリリースするのは楽しくない。誰も問題を受け取ったり、午前3時にインシデントで起こされたりするのは好きではない。
高速に動くことと安定したコードをリリースすることは、しばしば両立しないものとして提示されるが、そうであるべきではない。多くの場合、コードを脆弱でバグだらけにするのと同じ要因が、開発を遅らせる原因にもなる。結局のところ、常に何かを壊すことを心配しているなら、どうして高速に動けるだろうか?
この記事では、VS Code チームが最近完了した主要なエンジニアリングの取り組み、つまりコードベースで TypeScript の厳密な null チェックを有効にしたことについて共有したい。この作業によって、私たちはより高速に動き、より安定した製品をリリースできると信じている。厳密な null チェックの有効化は、バグを孤立したイベントとしてではなく、ソースコード内のより大きな危険の兆候として理解することによって動機づけられた。厳密な null チェックをケーススタディとして、この作業を動機づけたもの、問題に対処するための漸進的なアプローチをどのように考案したか、そして修正をどのように実装したかについて議論する。ハザードを特定し、削減するためのこの一般的なアプローチは、あらゆるソフトウェアプロジェクトに適用できる。
例
厳密な null チェックを有効にする前に VS Code が直面していた問題を説明するために、単純な TypeScript ライブラリを考えてみよう。TypeScript に慣れていなくても心配しないでほしい。具体的な内容は重要ではない。この架空の例は、VS Code コードベースで発生していた問題の種類と、そのような問題に対する従来の対応を説明するだけである。
私たちの例のライブラリは、架空の Web サイトのバックエンドから特定のユーザーのステータスを取得する単一のgetStatus関数で構成されている
export interface User {
  readonly id: string;
}
/**
 * Get the status of a user
 */
export async function getStatus(user: User): Promise<string> {
  const id = user.id;
  const result = await fetch(`/api/v0/${id}/status`);
  const json = await result.json();
  return json.status;
}
妥当に見える。リリースしよう!
しかし、新しいコードをデプロイした後、クラッシュの急増が見られる。コールスタックから判断すると、クラッシュはgetStatus関数で発生しているようだ。まずい!
さらに遡って調査すると、私たちの同僚のエンジニアの1人が、現在のユーザーのステータスを取得しようとして、誤ってgetStatus(undefined)を呼び出しているようだ。これにより、コードがundefined.idにアクセスしようとすると例外が発生する。単純なミスだ。原因がわかったので、修正しよう!
そこで、呼び出し元のコードを更新し、getStatusを更新してundefinedを処理するようにし、さらにドキュメントコメントに役立つ警告を追加する
/**
 * Get the status of a user
 *
 * Don't call this with undefined or null!
 */
export async function getStatus(user: User): Promise<string> {
  if (!user) {
    return '';
  }
  const id = user.id;
  const result = await fetch(`/api/v0/${id}/status`);
  const json = await result.json();
  return json.status;
}
そして、私たちは完全に本物のエンジニアなので、テストも書く
it('should return empty status for undefined user', async () => {
  assert.equals(getStatus(undefined), '');
});
素晴らしい!もうクラッシュしない。テストカバレッジも100%に戻った!私たちのコードは**完璧**に違いない。
数日後、ドーン!誰かがログに奇妙なもの、つまり/api/v0/undefined/statusへの膨大な数のリクエストに気づく。それは奇妙なユーザー名だ...
そこで、再度調査し、コードを再度修正し、さらに多くのテストを追加する。getStatus({ id: undefined })を呼び出していた人には、受動攻撃的なメールを送るかもしれない。
/**
 * Get the status of a user
 *
 * !!!
 * WARNING: Don't call this with undefined or null, or with a user without an id
 * !!!
 */
export async function getStatus(user: User): Promise<string> {
  if (!user) {
    return '';
  }
  const id = user.id;
  if (typeof id !== 'string') {
    return '';
  }
  const result = await fetch(`/api/v0/${id}/status`);
  const json = await result.json();
  return json.status;
}
完璧だ。しかし、念のため、getStatusへの呼び出しを導入するすべての変更は、上級エンジニアによって承認されるようにしよう。これで、これらの厄介なバグは永久に止まるはずだ...
そして、おそらく今回は次のクラッシュまで数日、あるいは数ヶ月かかるかもしれない。しかし、私たちのコードが二度と変更されない限り、クラッシュは発生するだろう。この特定の関数でなければ、コードベースのどこか別の場所で。
さらに悪いことに、すべての変更には、undefinedの防御的チェック、テストの変更または新しいテストの追加、チームの承認が必要になる。どうなっているんだ?私たちは皆、自分の役割を果たしているのに、まだバグがある!もっと良い方法があるはずだ。
ハザードの特定
上記の例のバグは明白に見えるかもしれないが、VS Code の開発中に同じ種類の問題に遭遇していた。毎回のイテレーションで、予期しないundefinedに関連するバグを修正した。そしてテストを追加した。そして、より良いエンジニアになると誓った。それらはすべて従来の対応だが、次のイテレーションでは再び同じことが起こった。これは一部のユーザーに VS Code の不便な体験をもたらすだけでなく、これらのバグとそれらに対する私たちの対応は、新機能の開発や既存のソースコードの変更の際に私たちを遅らせてもいた。
私たちは、バグを孤立したイベントとしてではなく、より大きな問題の症状/シグナルとして、新しい方法で理解し始める必要があることに気づいた。これらのバグに対する私たちの対応と、迅速に行動できないことへの不満も症状だった。これらの症状の根本原因を議論し始めたとき、いくつかの共通の要因が見つかった
- nullや- undefinedのプロパティへのアクセスなどの単純なプログラミングミスを検出できない。
- 仕様の不十分なインターフェース。どのパラメーターがundefinedまたはnullになり得るか、そしてどの関数がundefinedまたはnullを返す可能性があるか?多くの場合、関数の実装者は、呼び出し元とは異なる仮定のもとで作業していた。
- 型の奇妙さ。undefinedとnull。undefinedとfalse。undefinedと空文字列。
- コードを信頼したり、安全にリファクタリングしたりできないという感覚。
根本原因を特定することは良い第一歩だったが、私たちはさらに深く掘り下げたかった。これらのすべての場合において、善意のエンジニアがそもそもバグを導入することを許したハザードは何だったのか?そして、これらのすべての問題に共通する明らかなハザードをすぐに特定した。それは VS Code コードベースにおける厳密な null チェックの欠如だった。
厳密な null チェックを理解するには、TypeScript の目的が JavaScript に型を追加することであることを覚えておく必要がある。TypeScript の JavaScript のレガシーの結果として、デフォルトでは TypeScript はundefinedとnullをあらゆる値に使用することを許可する
// Without strict null checking, all of these calls are valid
getStatus(undefined); // Ok
getStatus(null); // Ok
getStatus({ id: undefined }); // Ok
この柔軟性により、JavaScript から TypeScript への移行は簡素化されるが、私たちの架空のウェブサイトの例のライブラリは、それがハザードでもあることを示した。このハザードは、VS Code の作業で特定した4つの根本原因(その他多数)の中心でもあった。
しかし、幸いなことに、TypeScript は厳密な null チェックというオプションを提供しており、これによりundefinedとnullが別個の型として扱われる。厳密な null チェックを使用する場合、null 許容になり得る型はすべてそのように注釈付けする必要がある
// With "strictNullCheck": true, all of these produce compile errors
getStatus(undefined); // Error
getStatus(null); // Error
getStatus({ id: undefined }); // Error
孤立したコード行を修正したり、テストを追加したりすることは、特定のバグだけを修正する対処療法だった。厳密な null チェックを有効にすることは、毎月報告されていたバグを修正するだけでなく、将来的にこれらのバグクラス全体が発生するのを防ぐ先行的な解決策である。オプションのプロパティに値があるかどうかを確認するのを忘れることもなくなる。関数が null を返すかどうか疑問に思うこともなくなる。そのメリットは明らかだった。
段階的な計画を立てる
問題は、コンパイラフラグを有効にするだけで、すべてが魔法のように修正されるわけではないことだった。VS Code のコアコードベースには、約1800の TypeScript ファイルがあり、50万行以上ある。これを"strictNullChecks": trueでコンパイルすると、約4500のエラーが発生した。うーん!
さらに、VS Code は少人数のコアチームで構成されており、私たちは素早く動くのが好きだ。これら4500の厳密な null エラーを修正するためにコードを分岐させることは、莫大なエンジニアリングオーバーヘッドを追加することになっただろう。そして、どこから始めればよいのだろうか?エラーのリストを上から下まで順に見ていくのか?加えて、ブランチでの変更は、チームの大部分がまだ作業しているメインには役立たないだろう。
私たちは、厳密な null チェックのメリットをチームのすべてのエンジニアにすぐに段階的に提供する計画を立てたかった。そうすれば、作業を管理しやすい変更に分割でき、それぞれの小さな変更がコードを少しずつ安全にするだろう。
これを行うために、厳密な null チェックを有効にした新しい TypeScript プロジェクトファイルtsconfig.strictNullChecks.jsonを作成し、最初はファイルがゼロだった。次に、このプロジェクトに個々のファイルを selectively 追加し、それらのファイル内の厳密な null エラーを修正し、変更をチェックインした。インポートがない、またはすでに厳密な null チェック済みのファイルのみをインポートするファイルを追加する限り、各イテレーションで修正する必要があるエラーは少数だった。
{
  "extends": "./tsconfig.base.json", // Shared configuration with our main `tsconfig.json`
  "compilerOptions": {
    "noEmit": true, // Don't output any javascript
    "strictNullChecks": true
  },
  "files": [
    // Slowly growing list of strict null check files goes here
  ]
}
この計画は妥当に見えたが、1つの問題は、メインで作業するエンジニアが通常、VS Code の厳密な null チェックされたサブセットをコンパイルしないことだった。すでに厳密な null チェックされたファイルへの偶発的な退行を防ぐために、tsconfig.strictNullChecks.jsonをコンパイルする継続的インテグレーションステップを追加した。これにより、厳密な null チェックを退行させるチェックインはビルドを壊すことが保証された。
また、厳密な null チェック済みプロジェクトにファイルを追加する関連する反復タスクの一部を自動化するために、2つのシンプルなスクリプトを作成した。最初のスクリプトは、厳密な null チェックの対象となるファイルのリストを出力した。ファイルは、それ自体が厳密な null チェックされたファイルのみをインポートする場合に対象と見なされる。2番目のスクリプトは、対象となるファイルを厳密な null プロジェクトに自動的に追加しようとした。ファイルの追加がコンパイルエラーを引き起こさなかった場合、そのファイルはtsconfig.strictNullChecks.jsonにコミットされた。
厳密な null 修正の一部自体を自動化することも検討したが、最終的にはこれに反対した。厳密な null エラーは、ソースコードをリファクタリングすべき良いシグナルとなることが多い。おそらく、型が null 許容である正当な理由がなかったのかもしれない。おそらく、実装者ではなく呼び出し元が null を処理すべきだったのかもしれない。これらのエラーを手動でレビューし修正することで、コードを厳密な null 互換にするために力ずくで対応するのではなく、コードを改善する機会が得られた。
計画の実行
次の数ヶ月間、私たちは厳密な null チェック済みのファイルの数をゆっくりと増やした。これはしばしば退屈な作業だった。ほとんどの厳密な null エラーは単純で、null アノテーションを追加するだけだった。しかし、他のエラーでは、コードの意図を理解するのが困難だった。値は意図的に未初期化のままにされていたのか、それとも実際にプログラミングミスがあったのか?
一般的に、私たちは主要なコードベースでTypeScript の null 非アサーションの使用を可能な限り避けるようにした。テストではより自由にこれを使用し、テストコードでの null チェックの欠如が例外を引き起こす場合、テストはとにかく失敗すると推論した。
このプロセス全体で落胆させられたのは、VS Code コードベースの厳密な null エラーの総数が決して減少しないように見えたことだ。むしろ、厳密な null チェックを有効にして VS Code 全体をコンパイルした場合、私たちの厳密な null 作業のすべてが、実際にはエラーの総数を増加させているように見えた!これは、厳密な null 修正がしばしば連鎖的な影響を与えるためである。関数がundefinedを返すことを正しくアノテーションすると、その関数のすべてのコンシューマーに厳密な null エラーが発生する可能性がある。残りのエラーの総数を心配するよりも、私たちはすでに厳密な null チェックされたファイルの数に焦点を当て、この総数が決して後退しないように努力した。
厳密な null チェックを有効にしても、厳密な null 関連の例外が二度と発生しなくなるわけではないことにも注意する必要がある。例えば、any型や不正なキャストは、厳密な null チェックを簡単にバイパスできる
// strictNullCheck: true
function double(x: number): number {
  return x * 2;
}
double(undefined as any); // not an error
配列の範囲外の要素にアクセスするのと同じように
// strictNullCheck: true
function double(x: number): number {
  return x * 2;
}
const arr = [1, 2, 3];
double(arr[5]); // not an error
さらに、TypeScript の厳密なプロパティ初期化も有効にしない限り、初期化されていないメンバーにアクセスしてもコンパイラは文句を言わないだろう
// strictNullCheck: true
class Value {
  public x: number;
  public setValue(x: number) {
    this.x = x;
  }
  public double(): number {
    return this.x * 2; // not an error even though `x` will be `undefined` if `setValue` has not been called yet
  }
}
この取り組みの目的は、VS Code 内の厳密な null エラーを100%排除することでは決してなかった(それは不可能ではないにしても、非常に困難だろう)。しかし、一般的な厳密な null 関連のエラーの大部分を防ぐことだった。また、コードを整理し、リファクタリングをより安全にする良い機会でもあった。95%の達成で私たちにとっては十分だった。
私たちの厳密な null チェックの計画と実行の全貌はGitHubで確認できる。VS Code チームの全メンバーと多くの外部コントリビューターがこの取り組みに参加した。この作業の推進者として、私が最も多くの厳密な null 関連の修正を行ったが、それは私のエンジニアリング時間の約4分の1を占めるに過ぎなかった。途中には確かにいくつかの苦痛もあった。多くの厳密な null 退行が、チェックイン後に継続的インテグレーションによってのみ検出されたことへの不満などだ。厳密な null 作業はいくつかの新しいバグも導入した。しかし、変更されたコードの量を考えると、物事は驚くほどスムーズに進んだ。
VS Code コードベース全体で最終的に厳密な null チェックを有効にした変更は、むしろ anticlimactic だった。それはいくつかのコードエラーをさらに修正し、tsconfig.strictNullChecks.jsonを削除し、メインのtsconfigで"strictNullChecks": trueを設定した。ドラマがなかったのは、まさに計画通りだった。そしてそれによって、VS Code は厳密な null チェックされた!
まとめ
このプロジェクトについて話すときに私がよく聞く質問の1つは、「それで、いくつのバグを修正したのですか?」というものだ。その質問はあまり意味がないと思う。VS Code では、厳密な null チェックの欠如に関連するバグを修正することに問題はなかった。通常は条件を追加し、おそらくテストを1つか2つ追加するだけだった。しかし、私たちは同じ種類のバグを何度も何度も繰り返し見ていた。これらのバグを修正することは、不必要に私たちを遅らせており、コードを完全に信頼できないことを意味していた。コードベースにおける厳密な null チェックの欠如はハザードであり、バグはこのハザードの単なる症状だった。厳密な null チェックを有効にすることで、私たちはコードベースと作業スタイルに多くの他のメリットをもたらすことに加えて、バグのクラス全体を防ぐために重要な作業を行ったのだ。
この記事の目的は、大規模なコードベースで厳密な null チェックを有効にする方法のチュートリアルになることではなかった。もしこの問題があなたにも当てはまるなら、魔法なしで健全な方法でそれが可能であることがわかったことを願う。(新しい TypeScript プロジェクトを開始する場合は、将来の自分のために"strict": trueをデフォルトとして始めることをお勧めする。)
私があなたに伝えたいのは、バグに対する対応が、テストを追加するか、非難するか、ということがあまりにも多いということだ。「もちろん、Bob はそのプロパティにアクセスする前に undefined をチェックすべきだった。」人々は善意を持っているが、間違いを犯すだろう。テストは有用だが、コストもかかり、書いたものだけをテストする。
代わりに、バグやあなたを遅らせる何かに出くわしたときは、急いで修正して次の問題に移るのではなく、一瞬立ち止まって、何がそれを引き起こしたのかを本当に探求してほしい。その根本原因は何だったのか?どのようなハザードが明らかになったのか?例えば、あなたのソースコードに危険なコーディングパターンが含まれていて、リファクタリングが必要かもしれない。そして、その影響に見合った方法でハザードに対処するよう努めてほしい。すべてを書き直す必要はない。必要な最小限の事前作業を行い、意味がある場合は自動化する。ハザードを減らし、今日の世界を少しずつ良くしていこう。
私たちは VS Code の厳密な null チェックにこのアプローチを採用し、将来他の問題にも適用するだろう。どのような種類のプロジェクトに取り組んでいても、あなたにも役立つことを願っている。
ハッピーコーディング、
Matt Bierner、VS Code チームメンバー @mattbierner