開発チームの皆さん、お疲れ様です。テクニカルリードの私だ。
今日のコードレビューで、また「なんとなく`Partial
APIの`PATCH`リクエストや、状態管理のストアにおけるイミュータブルな部分更新(`setState`的なもの)は、フロントエンド開発において最もバグが混入しやすい魔窟の一つだ。
「とりあえずオプショナルにしておけばいいか」という甘い考えは、ランタイムでのドメイン破壊や、存在しないプロパティへのアクセスという名の爆弾を未来の自分たちに残すことになる。
今回は、TypeScriptのコンパイラが型をどう評価しているのか、その深淵を覗きながら、Mapped Types(マッピング型)とInterfaceを融合させて「コンパイラが完全に味方をしてくれる、鉄壁の部分更新型システム」を構築する方法を叩き込む。
—
なぜ雑な `Partial` や手動の `type` 定義は破綻するのか
まず、よくあるアンチパターンから見ていこう。
// 良くあるダサい設計
interface User {
id: string;
name: string;
email: string;
settings: {
theme: ‘dark’ | ‘light’;
notifications: boolean;
};
}
// 1. 全てがオプショナルになるだけの雑なアプローチ
type BadUserPatch = Partial
// 問題点: settings全体を丸ごと上書きするしかなく、
// 「themeだけ変えたい」というネストされた部分更新が型レベルで表現できない。
// 2. 手動でオプショナルを並べた破綻するアプローチ
interface ManualUserPatch {
name?: string;
email?: string;
// 更新APIなのに、なぜかidまでオプショナルで書けてしまう(バグの温床)
}
実務のAPI設計において、`PATCH`リクエストが求めるものは「全プロパティの単なる弱体化(オプショナル化)」ではない。
- 更新不可な主キー(`id`など)の厳格な除外
- ネストされたオブジェクトに対する再帰的な部分更新(Deep Partial)の適用
- 存在しないプロパティの混入を防ぐ厳密性(Excess Property Checkingの維持)
これらを完全にコントロールするには、TypeScriptのMapped Typesのメカニズムをコードの血肉とする必要がある。
—
プリミティブを極める:自作 `StrictPartial` の実装
まずは、ビルトインの `Partial
Mapped Typesの基本形はこうだ。
type MyPartial
[K in keyof T]?: T[K];
};
`[K in keyof T]` でInterfaceのキーをイテレートし、`?` モディファイアでオプショナルに変換する。これ自体は基本だが、実務では「特定のキーを除外したい」「ネストしたオブジェクトも部分更新させたい」という要求が必ず発生する。
ここに、TypeScriptの型演算子(`Omit`, `Conditional Types`, 再帰)を組み合わせたプロダクション・グレードのDeep Patchシステムを提示する。
コピペで使える最強の「Deep Partial & Immutable Patch」パターン
/
- 1. プリミティブや配列、関数を適切に判定し、
- オブジェクトであれば再帰的にPartialを適用する高度なMapped Type
/
type DeepPartial
? T
: T extends Array
? Array
: T extends object
? {
[K in keyof T]?: DeepPartial
}
: T;
/
- 2. 特定のキー(IDや監査ログなど)を更新対象から強制的に除外するユーティリティ
/
type ImmutableKeys = ‘id’ | ‘createdAt’ | ‘updatedAt’;
/
- 3. インターフェースをベースにした鉄壁のPATCHリクエスト型ジェネレーター
/
export type CreatePatchPayload
—
現場でどう機能するか:プロダクションコードでの実践
では、先ほどの `User` インターフェースに対して、この型システムをどう適用するか。実際のAPIクライアントのコードを見てほしい。
interface UserProfile {
readonly id: string; // 主キーは当然書き換え不可
name: string;
email: string;
role: ‘admin’ | ‘editor’ | ‘viewer’;
profile: {
bio: string;
website: string;
socials: {
twitter: string;
github: string;
};
};
createdAt: string;
}
// —————————————————————–
// 型評価のテストケース(APIリクエスト層)
// —————————————————————–
type UserPatchPayload = CreatePatchPayload
/
↑ コンパイラが導出する型(推論結果のイメージ):
{
name?: string;
email?: string;
role?: ‘admin’ | ‘editor’ | ‘viewer’;
profile?: {
bio?: string;
website?: string;
socials?: {
twitter?: string;
github?: string;
};
};
}
/
// 【OKな例】ネストされた一部のプロパティだけを安全に更新できる
const validUpdate: UserPatchPayload = {
name: “Kulls Architect”,
profile: {
socials: {
twitter: “@kulls_ts”
// githubを書き忘れても、他のプロパティを汚染しなくてもコンパイルエラーにならない
}
}
};
// 【NGな例】絶対に更新してはいけないキーや、存在しないキーを弾く
const invalidUpdate: UserPatchPayload = {
id: “user_999”, // ❌ Error: Object literal may only specify known properties, and ‘id’ does not exist in type…
profile: {
bio: “Chief Architect”,
unknownKey: “value” // ❌ Error: 存在しないキーはExcess Property Checkingで即座に検知される
}
};
どうだ? これが、Mapped TypesとInterfaceを組み合わせた型安全の極みだ。
開発者は「どのプロパティを更新できて、どれが触ってはいけない領域なのか」を一切迷うことなく、IDEの補完(IntelliSense)の導くままにコードを書くだけで、堅牢なAPIペイロードを作り出せる。
—
テクニカルリードからの注意点:パフォーマンスとコンパイラ負荷
最後に、シニアエンジニアとしてパフォーマンスの観点についても言及しておこう。
今回紹介したような「再帰的なMapped Types(`DeepPartial` など)」は、非常に強力である反面、巨大なスキーマや複雑な型合成(Intersectionなど)の嵐の中で多用すると、TypeScriptの型チェッカー(TSServer)に重い負荷をかける。
- 巨大なモノリスなInterfaceをそのままDeepPartialにしない:
ドメインごとにInterfaceを適切に小さく分割(Interface Segregation Principle)し、必要な単位だけでMapped Typeを適用すること。
- 型の評価キャッシュを意識する:
複雑なユーティリティ型をインラインで書き散らすのではなく、今回のように名前付きの型エイリアス(`CreatePatchPayload
—
まとめ
TypeScriptにおけるInterfaceとMapped Typesの融合は、単なる「型合わせのパズル」ではない。それは、「ドメインのビジネスルールをコードの構造レベルで担保し、実行時エラーの余地をコンパイル時に焼き尽くす」ための強力なアーキテクチャ武器だ。
明日からのコードレビューでは、ただ動くだけの `Partial
プロフェッショナルなコードベースは、細部へのこだわりと、型システムへの深い敬意から作られる。
それでは、次のプルリクエストを楽しみにしている。