この記事は NTT コムウェア AdventCalendar 2024 24 日目の記事です。
記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。
はじめに
NTT コムウェアの山本です。
普段は、IPA の DSS を活用した、エンジニアの育成及び組織文化醸成の取り組みを実施しています
本記事では、TypeScript で「早く失敗」する方法について、自身の経験を踏まえた幾つかの Tips を提供したいと思います。
TypeScript って?
TypeScriptは、 JavaScript に静的型付けを提供するラッパー言語です。開発者は TypeScript でコーディングし、それを JavaScript に変換(トランスパイル/コンパイル)することで、ブラウザや NodeJS 上でコードを動作させることができます。
「早く失敗」って?
「早く失敗/Fail Fast」とは、「問題を早めに見つけて直すことが出来れば、手戻りが減り全体的なリスクやコストが抑えられる」という意味の標語です。
この言葉は、一見するとネガティブに聞こえるかもしれませんが、実際は商用一発勝負よりも E2E テストでバグが見つかる方が良いでしょうし、E2E テストよりも単体テストでバグが見つかる方がいいでしょう。
単体テスト実行エラーよりもコンパイルエラーの方が嬉しいですし、もっと言えば、コードの編集中にリアルタイムで問題が分かると最高でしょう。
TypeScript コーディングにおいても、「早く失敗」することは非常に重要です。
早く失敗するための Tips
Tip1: エディタを賢くする
TypeScript の直接的なテクニックという訳ではないですが、エディタが最適化されていればされているほど、コードの編集中にリアルタイムで問題が分かるようになります。
標準の TypeScript 言語サーバだけでも十分に賢いのですが、加えて以下を拡張機能として入れておくことをお勧めします。
この辺りの機能は、エディタだけでなく pre-commit などにも入れておくと良いと思います。
Tip2: string 型を使うときは、一旦立ち止まる
「文字列だから」という理由で、勢いで string 型使っていませんか?
実際は数個の候補文字列に絞れるのにも関わらず、 string 型が使用されているケースをよく見かけます
候補文字列リテラルのユニオン型で定義することで、typo や想定されていない候補値に対し、コンパイラが早期に問題を検出することができます。
type BadUser = {
username: string;
role: string;
};
type GoodUser = {
username: string;
role: 'admin' | 'member'; // リテラルのユニオンで定義
};
const user = {
username: 'badUser',
role: 'admim', // typo
};
const badUser: BadUser = user; // 実行するまで問題に気づかない
const goodUser: GoodUser = user; // ERROR: Type 'string' is not assignable to type '"admin" | "member"'.
Tip3: 型文脈にも DRY 原則を適用する
DRY(Don't Repeat Yourself = 繰り返しを避けろ)原則は、型定義においても同様に適用されるべきです。定義元の多重管理を避けることで、変更漏れによる問題を早期に検出できるようになります。
例えば以下のような型と変数があるとしましょう。
type SystemSupportLanguage = 'en' | 'jp';
type BadDictionary = {
// SystemSupportLanguage と同じ定義を繰り返している
en: string;
jp: string;
};
type GoodDictionary = {
// SystemSupportLanguage を参照して定義している
[language in SystemSupportLanguage]: string;
};
const badButterflyDict: BadDictionary = { en: 'Butterfly', jp: '蝶' };
const goodButterflyDict: GoodDictionary = { en: 'Butterfly', jp: '蝶' };
SystemSupportLanguage
型と BadDictionary
型で 'en'
や 'jp'
が二重管理されています。
ある時要件が変更になり、対応する言語にイタリア語を追加することになりました。
type SystemSupportLanguage = 'en' | 'jp' | 'it'; // italy を追加
この時、Good な型定義をしていれば、変更すべき箇所がコンパイルエラーとして早期検出されます。
const badButterflyDict: BadDictionary = { en: 'Butterfly', jp: '蝶' }; // 実行までエラーは出ない
const goodButterflyDict: GoodDictionary = { en: 'Butterfly', jp: '蝶' }; // ERROR: Property 'it' is missing in type '{ en: string; jp: string; }'
keyof 演算子を使用することで、BadDictionary
型から SystemSupportLanguage
型を逆に作ることもできます。
type BadDictionary = {
en: string;
jp: string;
};
type SystemSupportLanguage = keyof BadDictionary;
重要なのは「定義元が一元化されていること = DRY」です。
他にも、extends 句、ジェネリクス型、組み込みユーティリティ型なども、DRY 解消に役立ちます。
また、API の型をフロントとバックエンドで二重定義しないために、OpenAPI のような言語共通規格を使うのも良いでしょう。
Tip4: 常に 1 つのステートに対応する型を設計する
常に 1 つのステートに対応する型を設計しましょう。例えば、こんな型があったとします。
type BadFetchInfo<T> = {
status: 'success' | 'loading' | 'error';
data: T | null;
error: string | null;
};
リテラルのユニオン型で値が絞られており、ジェネリクス型で汎用性も高そうなので、一見問題はなさそうです。
ですがこの型は、「型定義には則っているのに存在しえない状態」を生み出してしまいます。
const result: BadFetchInfo = {
status: 'success',
data: null,
error: 'エラーが発生しました', // statusはsuccessなのに、エラーメッセージが含まれる
};
こうなると開発者は、定義したステートが「実際に存在しうる状態か」を常に意識しないといけません。これは開発者にとって大きな負担です。
取りうる状態と 1 対 1 で対応する型を定義することで、開発者はこの苦しみから解放されます。
// 取りうる全ての状態を個別に定義
type FetchSuccess<T> = { status: 'success'; data: T; error: null };
type FetchLoading = { status: 'loading'; data: null; error: null };
type FetchError = { status: 'error'; data: null; error: string };
// 取りうる状態のユニオンで定義する
type GoodFetchInfo<T> = FetchSuccess<T> | FetchLoading | FetchError;
こうすることで、存在し得ない状態に対しては人間ではなく TypeScript のコンパイラや言語サーバがエラーを出してくれるようになり、問題が早期に検出できるようになります。
ちなみにこの考え方は タグ付きユニオン(判別可能なユニオン) という型定義の手法と非常に相性がいいです。こちらもかなり汎用的な型定義手法なので、知らない方は是非調べてみてください。
型を厳密に定義することで、コードの堅牢性だけでなく、エディタ開発時のオートコンプリートの充実度にも影響します。
Tip5: Exhaustive Check(徹底的なチェック)を行う
リテラルのユニオン型に対し条件分岐している場合、元の型定義が更新された時に、条件分岐側でその変更を検知できるでしょうか?
答えは No です。
type SystemSupportLanguage = 'en' | 'jp';
if (language === 'jp') {
funcJp();
} else if (language === 'en') {
funcEn();
}
// 定義元に'fr'を追加
type SystemSupportLanguage = 'en' | 'jp' | 'fr';
// 'fr'の場合の処理の定義を忘れてもコンパイルエラーにはならないので、実装漏れに気づくのが難しい
if (language === 'jp') {
funcJp();
} else if (language === 'en') {
funcEn();
}
こんな時に使えるのが Exhaustive Check(徹底的なチェック)です。
通過しないはずの分岐に never 型の変数をおいておくことで、想定外の通過が発生しうる場合はコンパイルエラーを返すことができます。
// 定義元に'fr'を追加
type SystemSupportLanguage = 'en' | 'jp' | 'fr';
// 'fr'の条件分岐をしていないとコンパイルエラーになるため、実装漏れが明確
if (language === 'jp') {
funcJp();
} else if (language === 'en') {
funcEn();
} else {
const _exhaustiveCheck: never = language; // ERROR: Type 'fr' is not assignable to type 'never'.
}
注意点として、原則通過しない分岐を追加するため、テストカバレッジを取得している場合は影響を与える可能性があります。
また_exhaustiveCheck
変数自体は、定義のみで使用はしない変数になるため、リンタなどがunused-var
としてエラーを出す場合があります。
「_
から始まる変数は未使用でも無視する」などといったパターンを予め指定しておくといいでしょう。
Tip6: 型バリデーションライブラリを使う
Zod や Valibot といった型バリデーションライブラリは、型定義において非常に強力です。
例えば、定数から型を定義しその型チェック関数を作りたい場合、純粋な TypeScript だと、以下のような面倒な実装をしなければなりません。
// 取りうる候補値を as const 句で定数として定義
const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug'] as const;
type LogOption = {
loggerName: string;
logLevel: (typeof LOG_LEVELS)[number]; // 定数配列からユニオン型を定義
};
// LogOptionかどうかを判断する型ガード関数
const isLogOption = (obj: unknown): obj is LogOption => {
return (
// object 型であるかのチェック(JavaScript では null は object 型として扱われる)
typeof obj === 'object' &&
obj !== null &&
// loggerName が string であることのチェック
typeof (obj as LogOption).loggerName === 'string' &&
// logLevel が LOG_LEVELS で定義した値に含まれることのチェック
LOG_LEVELS.includes((obj as LogOption).logLevel)
);
};
const logOption: unknown = ...
// 型ガード関数でバリデーション
if (!isLogOption(logOption)) {
throw Error('invalid log option');
}
// バリデーションにパスした場合のみパース
const { loggerName, logLevel } = logOption;
型チェック部分が何をやっているかパッと分かりづらいですし、型チェック部分に実装ミスがあったとしても、なかなか気づくことができません。
型バリデーションライブラリはそんな課題を解決してくれます。以下は Zod の例です。
import { z } from 'zod';
const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug'] as const;
// Zod スキーマオブジェクトを定義
const logOptionSchema = z.object({
loggerName: z.string(),
logLevel: z.enum(LOG_LEVELS), // 定数配列からユニオン型を定義
});
// Zod スキーマオブジェクトから実際の型を生成(z.infer)
type LogOption = z.infer<typeof logOptionSchema>;
const logOption: unknown = ...
// Zod スキーマオブジェクトの parse 関数でバリデーションしつつパース(型が異なる場合はエラー)
const { loggerName, logLevel } = logOptionSchema.parse(logOption);
スキーマを定義するだけで、型と型チェック関数(兼パーサー)が勝手についてきます。
本来型と言うものは TypeScript の機能なので、JavaScript コンパイル時には消えてしまいます。しかしながら関数や値は JavaScript の機能ですので、コンパイル後も存在し続けます。
ですので、型チェック関数や定数に基づく型の生成といった、型(TypeScript)の文脈と値(JavaScript)の文脈を行き来するような実装は、ややこしくなりがちです。
そんなややこしい部分を、型バリデーションライブラリはいい感じに隠蔽してくれます。
他にも型バリデーションライブラリには色々便利な機能が揃っています。試してみたいと思った方は、是非調べてみてください!
終わりに
TypeScript で「早く失敗」するための Tips を記載しましたが、いかがだったでしょうか?
開発のサプライチェーンにおいて、エディタやコンパイラと言う一番身近な場所で「早く失敗」することには、デプロイやテストを整備するだけでは得られない、非常に大きな価値があると思います。
今後の皆さんの「早い失敗」に向けて、上記の Tips が一つでも参考になれば幸いです。
それでは、これからも楽しい TypeScript ライフを!