Visual Studio Code の厳格な null チェック
2019年5月23日 Matt Bierner, @mattbierner
安全はスピードを可能にする
速く動くのは楽しいものです。新しい機能をリリースし、ユーザーを喜ばせ、コードベースを改善するのは楽しいものです。しかし同時に、バグだらけの製品をリリースするのは楽しくありません。誰も問題が発生したり、午前3時にインシデントのために起こされたりするのは好きではありません。
速く動くことと安定したコードをリリースすることはしばしば両立しないものとして提示されますが、そうであるべきではありません。多くの場合、コードを脆弱でバグだらけにする同じ要因が、開発を遅らせる原因にもなっています。結局のところ、常に物事を壊すことを心配している場合、どうすれば速く動けるでしょうか?
この投稿では、VS Code チームが最近完了した主要なエンジニアリングの取り組み、つまりコードベースで TypeScript の 厳格な null チェック を有効にしたことについて共有したいと思います。私たちは、この作業によって、より速く動けるようになり、より安定した製品をリリースできるようになると信じています。厳格な null チェックを有効にしたのは、バグを孤立したイベントとしてではなく、ソースコード内のより大きな危険の兆候として理解することに動機付けられました。厳格な null チェックをケーススタディとして、私たちの作業の動機、問題に対処するための段階的なアプローチをどのように考え出したか、そして修正をどのように実装したかについて説明します。この危険を特定して軽減するための一般的なアプローチは、あらゆるソフトウェアプロジェクトに適用できます。
例
厳格な null チェックを有効にする前に VS Code が直面していた問題を説明するために、簡単な TypeScript ライブラリについて考えてみましょう。TypeScript を初めて使用する場合でも心配しないでください。詳細は重要ではありません。この架空の例は、VS Code コードベースで私たちが直面していた問題の種類と、そのような問題への従来の対応策をいくつか示すためのものです。
私たちの例のライブラリは、架空のウェブサイトのバックエンドから特定のユーザーのステータスを取得する単一の `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` 関数で発生しているようです。大変だ!
少し遡ってみると、仲間のエンジニアの一人が現在のユーザーのステータスを取得しようと誤って `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` vs `null`。`undefined` vs `false`。`undefined` vs 空文字列。
- コードを信頼できず、安全にリファクタリングできないと感じること。
根本原因を特定することは良い第一歩でしたが、私たちはさらに深く掘り下げたいと考えました。そもそも善意のエンジニアがバグを導入することを許したすべてのケースにおける 危険 は何だったのでしょうか?そして、私たちはこれらの問題すべてに共通する明白な危険をすぐに特定しました。それは、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 チェックを使用する場合、nullable になる可能性のある型はすべてそのように注釈を付ける必要があります。
// 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 チェックを有効にし、最初はファイルがゼロの `tsconfig.strictNullChecks.json` という新しい TypeScript プロジェクトファイルを作成しました。次に、個々のファイルをこのプロジェクトに選択的に追加し、これらのファイルの厳格な 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 エラーは、多くの場合、ソースコードをリファクタリングする必要があるという良い兆候です。タイプが nullable である理由が適切でなかったのかもしれません。実装者ではなく、呼び出し元が null を処理する必要があるのかもしれません。これらのエラーを手動でレビューして修正することで、コードをより良くする機会が得られました。無理やり厳格な null 互換にするのではなく。
計画の実行
その後数ヶ月にわたって、厳格な null チェックされたファイルの数をゆっくりと増やしていきました。これは多くの場合、退屈な作業でした。ほとんどの厳格な null エラーは単純でした。null 注釈を追加するだけです。他のエラーについては、コードの意図を理解するのが困難でした。値は意図的に初期化されていないままにされているのか、それとも実際にプログラミングミスがあるのか?
一般的に、メインのコードベースでは、TypeScript の not-null アサーション をできるだけ使用しないようにしました。テストコードではより自由に使用しましたが、テストコードでの null チェックの欠如が例外を引き起こす場合は、いずれにせよテストは失敗すると考えました。
プロセス全体の落胆させる側面の 1 つは、VS Code コードベースでの厳格な null エラーの総数が決して減少するように見えなかったことです。どちらかと言えば、VS Code 全体を厳格な null チェックを有効にしてコンパイルすると、私たちの厳格な 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 リグレッションがキャッチされたことへの多少の不満など、途中で少し苦痛はありましたが、変更されたコードの量を考えると、物事は驚くほどスムーズに進みました。
最終的に VS Code コードベース全体の厳格な null チェックを有効にした 変更 は、むしろ拍子抜けするものでした。それは、さらにいくつかのコードエラーを修正し、`tsconfig.strictNullChecks.json` を削除し、メインの `tsconfig` で `"strictNullChecks": true` を設定しました。ドラマの欠如はまさに計画通りでした。そして、それにより、VS Code は厳格な null チェックされました!
結論
このプロジェクトについて人々に話すときに私がよく聞かれる質問の 1 つは、「それで、何個のバグを修正しましたか?」ということです。私はその質問はあまり意味がないと思います。VS Code では、厳格な null チェックの欠如に関連するバグの修正に問題はありませんでした。通常、条件を追加し、おそらく 1 つまたは 2 つのテストを追加することを含みます。しかし、私たちは同じタイプのバグを何度も何度も見ていました。これらのバグを修正することは、私たちを不必要に遅らせており、コードを完全に信頼できないことを意味していました。コードベースでの厳格な null チェックの欠如は危険であり、バグはこの危険の症状にすぎませんでした。厳格な null チェックを有効にすることで、コードベースと作業スタイルに他の多くの利点をもたらすことに加えて、バグのクラス全体を防ぐために重要な作業を行ってきました。
この投稿の目的は、大規模なコードベースで厳格な null チェックを有効にする方法のチュートリアルにすることではありませんでした。この問題があなたに当てはまる場合は、魔法なしで正気な方法で実行できることを願っています。(新しい TypeScript プロジェクトを開始する場合は、将来の自分のために、デフォルトとして `"strict": true` で開始してください。)
私が皆さんに持ち帰ってほしいのは、あまりにも頻繁に、バグへの対応がテストを追加するか、非難することであるということです。「もちろん、ボブはプロパティにアクセスする前に undefined をチェックすることを知っておくべきだった。」人々は善意を持っていますが、間違いを犯します。テストは役立ちますが、コストもかかり、私たちが書いたものをテストするだけです。
代わりに、バグや何か他に遅れていることに遭遇した場合は、急いで修正して次の問題に移るのではなく、少し立ち止まって、何が原因なのかを本当に探求してください。その根本原因は何でしたか?それはどのような危険を明らかにしますか?たとえば、ソースコードに危険なコーディングパターンが含まれており、リファクタリングが必要になる場合があります。次に、その影響に比例した方法で危険に対処するように努めてください。すべてを書き換える必要はありません。必要な最小限の先行作業を行い、意味がある場合は自動化します。危険を減らし、今日の世界を段階的に改善してください。
私たちは、厳格な null チェック VS Code でこのアプローチを採用し、将来的に他の問題にも適用します。あなたがどのような種類のプロジェクトに取り組んでいるかに関わらず、それがあなたにも役立つことを願っています。
ハッピーコーディング、
Matt Bierner, VS Code チームメンバー @mattbierner