👀概要
パーソルホールディングス デジタル開発室の足立です。
私たちはグループ全体の事業やサービスにおけるテクノロジー実装・活用を強化するCoE(Center of Excellence)として活動しています。
今回はウェブアプリケーションの開発で広く使われるTypeScriptをターゲットにして、リファクタリングの一環として、「型を絞り込む」ということを紹介します。
また、最近はAIエージェントを用いた開発が主流だと思いますので、リファクタリングに用いたプロンプトも一緒に掲載しますので、
みなさんもぜひトライしてみてください。
👨👩👧👦対象者(Who)
- TypeScriptで開発を行う開発者
📌関連リンク
- サバイバルTypeScript
- TypeScript の Narrowing
- Branded Type ベストプラクティス 検索
- 【TS】
${number}型に入るアルファベット、いくつ言える?【テンプレートリテラル型の重箱の隅】
🗒️目次
- 背景
- 型の絞り込みとは
- 型の絞り込みテクニック
- Literal Type
- Template Literal Type
- const assertion
- Branded Type
- Discriminated Union
- まとめ
📝 内容
背景
TypeScriptでは値をうまく型で表現することで、型システムのサポートを受けながら堅牢な開発ができます。
型を広く定義することで処理を共通化したり、逆に型を狭く定義することで不正な操作やロジックを防いだりと、
このバランスをとることがTypeScriptでは重要になります。
しかし、昨今のAIエージェントによる開発が進む中で、型の定義はどうしても粗くなりがちだと感じています。
型をより厳密に定義する作業はAIが苦手な分野のひとつではないかと考えています。
(AIエージェントの書いたany型を直した経験は誰にでもあるのではないでしょうか…)
LLMの性質としてより汎用的なものを出力しようとするため、粗い型を付けること(Over approximation)は得意でも、厳密な型を見つけること(Under approximation)は難しいのだと思います。
そこで、AIエージェントで開発を進めつつも、定期的に型の手入れをしてコードの品質を上げていこうというのがこの記事の趣旨です。
この「粗い型を実態に近づける」作業を型の絞り込み(Type Narrowing) と呼び、その考え方や具体的な方法を説明します。
型の絞り込みとは
型はデータの取りうる値の集合を表現するのに使います。 たとえば、データが数値を取りうるのであればnumber、文字列の値を取りうるのであればstringなどと型をつけます。
しかし、型は必ずしも厳密に値の集合を表現しているとは限りません。 1〜5の整数しか取りえないデータに対してnumberと型をつけるのはTypeScriptの型システムとしては妥当ですが、 numberは6や-100なども含むため、実態よりも広い範囲を許容してしまっています。
型の絞り込みとは、このように実態よりも広い型に対して、より狭く正確な型を付け直し、データが本来取りうる値の集合に近づける作業のことです。
型の絞り込みにより、以下のメリットが挙げられます。
- 不正な値や意図しないロジックをコンパイル時に防げる: 取りうる値の範囲を型で制約することで、範囲外の値の代入や、本来ありえない条件分岐、不正な演算などをコンパイル時に検出できる
- 型がより強力なドキュメントになる: 型名や型定義を見れば、どんな値を取りうるかが明確になる。これはAIエージェントにとっても効果的で、詳細な型はコード生成時のコンテキストとして利用できる。
ただし、いいことづくめではありません。下記のようなデメリットも考えられます。
- 拡張性が乏しくなる: 値の集合が変わるたびに詳細化した型を修正しなおす手間がある
- 型定義が複雑化する: 過度な詳細化は型定義自体の可読性を下げる場合がある
導入や保守するコストを考え、無理のない範囲でできそうな詳細化のテクニックを次の章で紹介します。
型の絞り込みテクニック
Literal Type
Literal Type(リテラル型)は特定の値のみを表現する型です。
特定の値しか取りえないことがわかっているのであれば、Literal Typeを使って表現することでその意図がより明確になります。
ユニオン型と組み合わせて、取りうる値を列挙してしまうという方法がよく使われ、ステータスを表現するときなどによく使います。
const x: 1 = 1;
// ユニオン型と合わせて使うことで何通りかの値を取りうるstateを表現できる
let status: "ok" | "ng" = "ng";
if (status === "ok") {
// statusの値は'ok'であると型レベルで保証される
} else {
// statusの値は'ng'であると型レベルで保証される
// もし型がstringの場合は、型レベルでは'ng'であると保証されない
}
AIエージェントを使ってリファクタリングするには、たとえば以下のように指示してみるとよいでしょう。
@hogehoge を対象にして、Literal Typeへの型リファクタリングを行います。
## 適用条件(すべて満たす場合に置き換える)
1. string型またはnumber型で定義されている
2. 取りうる値が有限個であり、コード内から網羅的に特定できる
- 条件分岐(if, switch)で特定の値と比較されている
- 特定の値のみを代入している箇所が複数ある
- コメントやドキュメントで取りうる値が明記されている
- DBスキーマやAPI仕様で値が定義されている
3. 以下のいずれかに該当し、型を狭めることに意味がある
- 条件分岐で型の絞り込み(narrowing)が活用できる
- 不正な値の代入をコンパイル時に検出できる
- Discriminated Unionの判別プロパティとして機能する
## 除外条件(いずれかに該当する場合はスキップ)
1. ユーザー入力やAPIレスポンスなど、値が外部要因で変動する
2. 取りうる値が頻繁に追加・変更される見込みがある
3. 取りうる値の全量がコードベースから確信を持って特定できない
Template Literal Type
Template Literal Type(テンプレートリテラル型)は文字列を対象にして、特定のパターンや構造を型で表現することができます。
type ApiVersion = "v1" | "v2";
type Resource = "users" | "orders" | "products";
type ApiPath = `/${ApiVersion}/${Resource}`;
// -> "/v1/users" | "/v1/orders" | "/v1/products" | "/v2/users" | "/v2/orders" | "/v2/products"
function fetchApi(path: ApiPath) {
return fetch(`https://api.example.com${path}`);
}
// 有効なパスのみ受け付ける
fetchApi("/v1/users"); // OK
fetchApi("/v2/orders"); // OK
fetchApi("/v3/users"); // コンパイルエラー: v3は存在しない
fetchApi("/v1/accounts"); // コンパイルエラー: accountsは存在しない
// IDの部分は任意のnumberを許容しつつ、パスの形式だけ制約する
type DetailPath = `/${ApiVersion}/${Resource}/${number}`;
function fetchDetail(path: DetailPath) {
return fetch(`https://api.example.com${path}`);
}
fetchDetail("/v1/users/123"); // OK
fetchDetail("/v1/users/abc"); // コンパイルエラー: abcはnumberではない
fetchDetail("/v1/users/"); // コンパイルエラー: IDが指定されていない
ただし、Template Literal Typeには以下のような罠も存在し、時として意図しない入力も受け付けうるので注意が必要です。
【TS】${number} 型に入るアルファベット、いくつ言える?【テンプレートリテラル型の重箱の隅】
AIエージェントへの指示
@hogehoge を対象にして、Template Literal Typeへの型リファクタリングを行います。
## 適用条件(すべて満たす場合に置き換える)
1. string型または文字列のUnion型で定義されている
2. 値が以下のような規則的なパターン・構造を持っている
- 共通のプレフィックス/サフィックスがある(例: "/api/v1/users", "/api/v1/orders")
- 区切り文字で分割された複数のセグメントで構成されている(例: "users:read", "users:write")
- 既存のUnion型の組み合わせで網羅できる(例: ApiVersion × Resource)
3. パターンの構成要素が、コード内の既存の型定義・定数・enumから特定できる
## 除外条件(いずれかに該当する場合はスキップ)
1. ユーザー入力やAPIレスポンスなど、パターンが外部要因で変動する文字列
2. Union型の組み合わせ爆発により生成される型が100パターンを超える場合
3. `${number}` や `${string}` を多用しないと表現できず、実質的にstring型と変わらない制約しか得られない場合
const assertion
const assertion(constアサーション)は、as constをつけることでリテラル値をそのままLiteral Typeとして推論させることができます。
TypeScriptはオブジェクトや配列のプロパティをstringやnumberなどに拡大(widening)して推論しますが、as constをつけることでwideningを防ぐことができます。
ただし、as constをつけるとreadonly属性も付与されるので、プロパティに対する再代入ができなくなることに注意が必要です。スプレッド構文と組み合わせて値を加工するとよいでしょう。
// as constなし: wideningされる
const defaults = { mode: "production", timeout: 5000 };
// defaultsの型: { mode: string; timeout: number; }
const config = { ...defaults, timeout: 10000 };
// configの型: { mode: string; timeout: number; }
// modeがstringに拡大されているので、modeが"production"であると推論できない
// as constあり: スプレッド構文で展開してもLiteral Typeが維持される
const defaults = { mode: "production", timeout: 5000 } as const;
// defaultsの型: { mode: "production"; timeout: 5000; }
const config = { ...defaults, timeout: 10000 };
// configの型: { mode: "production"; timeout: number; }
// modeが"production"のままなので、他の値が入る可能性が型レベルで否定できる
AIエージェントへの指示
@hogehoge を対象にして、const assertionによる型のリファクタリングを行います。
## 適用条件(すべて満たす場合に `as const` を付与)
1. オブジェクトリテラルまたは配列リテラルで初期化されている
2. 以下のいずれかに該当し、Literal Typeであることに意味がある
- Union型やDiscriminated Unionの判別プロパティとして参照されている
- 型引数の制約(extends "foo" | "bar" など)に渡されている
- 条件分岐(if, switch)でリテラル値との比較に使われている
- 関数の引数としてLiteral Typeを要求する型に渡されている
## readonly起因の型エラー対処
`as const` 付与後にreadonly起因の型エラーが発生する箇所は、該当するコードをimmutableな書き方に書き換えてください。たとえばスプレッド構文(`{ ...obj }` / `[...arr]`)で新しいオブジェクト/配列を生成する形に変更する方法があります。
immutableな書き方に書き換えても解決できない場合のみ `as const` の付与を見送ってください。
Branded Type
Branded Type(ブランド型)は、構造的には同じ型(たとえばstring同士、number同士)に対して、意味的な区別を型レベルで付与するテクニックです。
TypeScriptは構造的部分型を採用しているため、同じ構造の型は互換性を持ちますが、Branded Typeを使うことで「UserId」と「OrderId」のように意味が異なる値の混同をコンパイル時に防ぐことができます。
// symbolを使ったBranded Typeの定義
// declare constで宣言することで、値を作らず型だけ使用する
declare const UserIdBrand: unique symbol;
declare const OrderIdBrand: unique symbol;
type UserId = string & { [UserIdBrand]: never };
type OrderId = string & { [OrderIdBrand]: never };
// コンストラクタ関数を用意する
function toUserId(id: string): UserId {
return id as UserId;
}
function toOrderId(id: string): OrderId {
return id as OrderId;
}
const userId = toUserId("user-123");
const orderId = toOrderId("order-456");
// 構造的にはどちらもstringだが、型レベルで区別される
function getUser(id: UserId) {
return fetch(`/api/users/${id}`);
}
getUser(userId); // OK
getUser(orderId); // コンパイルエラー: OrderIdをUserIdとして使えない
Branded Typeは、同じプリミティブ型でも意味が異なる値(金額と個数、緯度と経度など)の取り違えを防ぎたい場面で広く活用できます。値の生成はコンストラクタ関数を経由することで可能です。
AIエージェントへの指示
@hogehoge を対象にして、Branded Typeへの型リファクタリングを行います。
Branded Typeの定義にはunique symbolを使用してください。
## 適用条件(すべて満たす場合に置き換える)
1. 同じプリミティブ型(string, number)で意味が異なる値が複数存在する
- 例: userId / orderId、金額 / 個数、緯度 / 経度
2. それらの値を取り違えて関数に渡すと、実行時バグにつながる箇所がある
## 除外条件(いずれかに該当する場合はスキップ)
1. 該当するプリミティブ型の用途が1種類しかなく、取り違えるリスクがない
2. 外部ライブラリの型定義と競合し、型エラーの解消コストが大きい
Discriminated Union
Discriminated Union(判別可能なユニオン型)は、共通の判別プロパティを持つオブジェクト型のユニオンです。
判別プロパティの値によって型を絞り込む(narrowing)ことができ、各分岐内で型安全にプロパティへアクセスできます。
// 各状態で持つデータが異なるAPIレスポンスを型で表現する
type ApiResponse =
| { status: "loading" }
| { status: "success"; data: { id: string; name: string }[] }
| { status: "error"; error: { code: number; message: string } };
/*
たとえば以下のような定義にしてしまうと、下記に挙げる型安全なプロパティアクセスができなくなる
type ApiResponse = {
status: string;
data?: { id: string; name: string }[];
error?: { code: number; message: string };
}
*/
function handleResponse(response: ApiResponse) {
switch (response.status) {
case "loading":
// この分岐では response は { status: "loading" } に絞り込まれる
console.log("読み込み中...");
break;
case "success":
// この分岐では response.data に安全にアクセスできる
console.log(`${response.data.length}件取得`);
break;
case "error":
// この分岐では response.error に安全にアクセスできる
console.log(`エラー: ${response.error.message}`);
break;
}
}
Discriminated Unionを使うことで、オプショナルプロパティの乱用を防ぎ、各状態で「何が存在し、何が存在しないか」を型レベルで保証できます。
特にAPIレスポンスやフォームの状態管理、イベントハンドリングなど、状態に応じてデータ構造が変わる場面で有効です。
AIエージェントへの指示
@hogehoge を対象にして、Discriminated Unionへの型リファクタリングを行います。
## 適用条件(すべて満たす場合に置き換える)
1. オブジェクト型にオプショナルプロパティが複数あり、特定のプロパティの値によって他のプロパティの有無が決まる
- 例: statusが"success"のときだけdataが存在し、"error"のときだけerrorが存在する
2. 条件分岐(if, switch)で特定のプロパティの値をチェックした後に、他のプロパティにアクセスしている
3. 判別プロパティとなりうるプロパティが存在する
- 文字列リテラルまたは数値リテラルで値が有限個に定まる
## 除外条件(いずれかに該当する場合はスキップ)
1. オプショナルプロパティ間に状態の依存関係がなく、各プロパティが独立して存在・不在になる
2. 判別プロパティとなる値が動的に決まり、リテラル型で列挙できない
3. 既にDiscriminated Unionで定義されている
まとめ
今回は「型の絞り込み」について取り上げ、実務でも使いやすい例をいくつか挙げました。
特にAIエージェントによるコーディングでは、
- 型は大雑把に付けられることが多く、型の絞り込みによるリファクタリングを細かく行うことは効果的
- 詳細化した型により、細かく指示をしなくてもコンテキストが伝わりやすくなる
- 型システムという明確な判断基準によって誤ったロジックの生成を防げる
という利点があり、コードベースの規模が大きくなっても、AIエージェントが生成するコードの品質を維持しやすくなります。
ぜひ試してみてください!