インターフェースと型エイリアスの境界線を超えろ:実務で勝つカスタムユーティリティ型の設計論
コードレビューをしていて、最もよく見かけるアンチパターンの一つがこれだ。
// よくある「とりあえず全部Optionalにする」地獄
type User = {
id: string;
name: string;
email: string;
profile: {
bio: string;
website: string;
};
};
type PartialUser = Partial
組み込みの `Partial
テクニカルリードとして言おう。真に保守性の高いフロントエンド/Node.jsアプリケーションは、標準ユーティリティ型の「その先」を自作できているかで決まる。
今回は、Type Alias(型エイリアス)とConditional Types(条件付き型)、Mapped Types(マッピングされた型)を極限まで駆使し、実務の現場で遭遇する複雑なデータ構造を制圧するカスタムユーティリティ型の設計論を伝授する。
—
1. プリミティブな理解の先へ:なぜ `Pick` と `Omit` だけでは戦えないのか
まず、TypeScriptのコンパイラが型をどう評価しているか、そのメカニズムを思い出してほしい。
`Pick` の実装はこうだ。
type Pick
[P in K]: T[P];
};
ここで重要なのは、`K extends keyof T` という制約と、Mapped Types の反復処理だ。しかし、このアプローチには「浅い(Shallow)」という致命的な弱点がある。ネストされたプロパティの一部だけをオプショナルにしたい場合や、特定のキーを除外した上で部分的な更新をかけたい場合、組み込みの型は無力だ。
ここから、実務の現場で本当に必要とされる3つの高度なカスタムユーティリティ型を実装していこう。
—
2. プロダクションコードで即採用できる「極限のカスタムユーティリティ型」3選
パターンA:ネストされたオブジェクトを完全再帰的に部分更新可能にする `DeepPartial`
APIのPATCHリクエストや、状態管理(ZustandやReduxなど)のストア更新関数において、ネストされた構造の一部だけを渡せるようにしたい場面は多々ある。
以下のコードは、オブジェクトだけでなく配列やプリミティブ型をも考慮し、型安全に再帰を回す `DeepPartial` だ。
/
- Tがプリミティブ、関数、または特殊な型であるかを判定し、
- オブジェクト構造であれば再帰的にPartialを適用するユーティリティ型
/
export type DeepPartial
? T
: T extends Date | RegExp | Map
? T
: T extends object
? { [K in keyof T]?: DeepPartial
: T;
// — 使用例 —
type AppConfig = {
env: ‘development’ | ‘production’;
server: {
host: string;
port: number;
ssl: {
enabled: boolean;
certPath: string;
};
};
};
// サーバのssl設定だけを部分的に更新するペイロードを定義
const updatePayload: DeepPartial
server: {
ssl: {
enabled: true,
// certPathを書き忘れてもコンパイルエラーにならない(DeepPartialの恩恵)
},
},
};
チーフアーキテクトの視点:
安易に `any` や `Record
—
パターンB:特定のキーの型を強制的に書き換える `Override`
APIのレスポンス型(例: 日付が `string` で返ってくる)を、フロントエンドのドメインモデル(例: `Date` 型に変換済み)にマッピングする際、既存の型の一部だけを上書きしたいケースがある。
/
- Tが持つキーのうち、Uと重複するキーはUの型で上書きし、残りはTの型を維持する
/
export type Override
// — 使用例 —
type RawApiUser = {
id: string;
createdAt: string; // APIからは文字列
updatedAt: string;
};
type DomainUser = Override< RawApiUser, { createdAt: Date; // Date型に強制変換 updatedAt: Date; } >;
// コンパイル結果の型チェック
const user: DomainUser = {
id: ‘usr_001’,
createdAt: new Date(), // 正しくDate型が要求される
updatedAt: new Date(),
};
なぜ `& (交差型)` を使うのか?
単純なマッピングよりも、既存の構造体に差分(Diff)をマージするこのイディオムは、型推論のパフォーマンスが高く、コンパイラの負荷を最小限に抑えられる。
—
パターンC:必須プロパティとオプショナルプロパティを型レベルで抽出する `RequiredKeys` / `OptionalKeys`
フォームのバリデーションロジックや、APIクライアントの自動生成レイヤーにおいて、「どのキーが必須で、どのキーがオプショナルか」を型から動的に導出したい瞬間がある。これは高度なMapped TypesとConditional Typesの融合技だ。
/
- オブジェクトTの中から「必須(Optionalではない)キー」だけを抽出する
/
export type RequiredKeys
[K in keyof T]-?: {} extends Pick
}[keyof T];
/
- オブジェクトTの中から「オプショナルなキー」だけを抽出する
/
export type OptionalKeys
[K in keyof T]-?: {} extends Pick
}[keyof T];
// — 使用例 —
type Product = {
id: string; // 必須
name: string; // 必須
description?: string;// オプショナル
price: number; // 必須
discountRate?: number; // オプショナル
};
type ReqKeys = RequiredKeys
// 評価結果: “id” | “name” | “price”
type OptKeys = OptionalKeys
// 評価結果: “description” | “discountRate”
このコードの凄み:
`{}`(空オブジェクト)が `Pick
—
3. パフォーマンスとスケーラビリティの罠:TypeScriptコンパイラを殺さないために
高度な型エイリアスを書き始めると、チームメンバーから「IDEの動作が重くなった」「ビルド時間が爆発した」という悲鳴が上がることがある。
TypeScriptの型システムはTuring Complete(チューリング完全)であるため、無秩序な再帰や複雑すぎるユニオン型の分散(Distributive Conditional Types)は、コンパイラの型推論エンジンに多大な負荷をかける。
1. 無駄な再帰を避ける
例えば、無限にネストする可能性のあるJSON型を定義する場合、深さ制限やガードを設けるべきだ。浅い階層で完結する設計であれば、再帰的なユーティリティ型ではなく、明示的なインターフェースの拡張を選ぶ方がコンパイル速度の観点からは健全である。
2. 型の評価キャッシュを意識する
巨大なユニオン型(例:数百の文字列リテラル)に対して複雑なMapped Typesを適用すると、コンパイラはすべての組み合わせを評価し直す。共通して使うカスタムユーティリティ型は、一度独立したファイル(`types/utils.ts` など)に切り出し、モジュール単位で型推論結果がキャッシュされるように構造化せよ。
—
4. 総括:型は「ドキュメント」であり「契約」である
私たちが書くTypeScriptの型は、単なるエラーチェックのツールではない。それは、「このコードベースにおいて、データはこういう形をしていなければならない」という未来の自分やチームメイトへの強力な契約書(ドキュメント)である。
組み込みのユーティリティ型に縛られるな。ドメインの複雑さに合わせて自らの手で型を彫刻し、コンパイラを味方につけた極限の型安全性と開発生産性を手に入れてほしい。
コードレビューで「なぜこの型が必要なのか」を語れるエンジニアであれ。君たちのコードベースの進化を期待している。