【実務・中級編】関数引数における「Default Parameters」と「Partial」の組み合わせによる設定値の柔軟なマージ – TypeScript コア・型システムの基礎解析バイブル

こんにちは。テクニカルリードの私だ。
今日のコードレビューで、またこんな「よくある妥協コード」を見かけた。

type Options = {
timeout: number;
retries: number;
endpoint: string;
};

// ❌ ありがちだが、実務では致命的にダサい実装
function fetchWithConfig(options?: Partial) {
const defaultOptions: Options = {
timeout: 5000,
retries: 3,
endpoint: ‘/api/default’,
};

// 1つずつ上書き?それとも Object.assign? 型の安全網が壊れている
const config = { …defaultOptions, …options };

// ここで config.timeout が本当に number か、コンパイラは信用しきれない(厳密には通るが拡張性に難あり)
return apiCall(config);
}

おいおい、待ってくれ。フロントエンドのコンポーネント設計でも、バックエンドとのAPIクライアント層でも、「デフォルト設定を持つオブジェクトを、呼び出し側から `Partial` で部分的に上書きさせたい」という要件は、息をするように頻出する。

しかし、この適当なスプレッド構文 (`{ …defaults, …options }`) に頼った実装は、型システム(TypeScript Compiler)の恩恵を半分捨てているようなものだ。ネストしたオブジェクトがある場合、スプレッド構文は浅い(シャローな)マージしか行わず、内部のプロパティが `undefined` で吹き飛ぶバグの温床になる。

今回は、TypeScriptの型システムを極限までドライに使い倒し、「デフォルト値の強制」と「Partialによる柔軟な上書き」を完璧に両立させるプロダクション・グレードの設計パターンを伝授しよう。

—

なぜ「単純な `Partial` とスプレッド構文」では破綻するのか?

まず、TypeScriptのコンパイラが型をどう評価しているかを思い出してほしい。

`Partial` は、`T` のすべてのプロパティをオプショナル(`T[K] | undefined`)にするユーティリティ型だ。これを関数の引数にそのまま使うと、呼び出し側は自由記述を手に入れるが、関数内部の実装者は「本当にその値が存在するか」の防衛的コード(あるいは不明瞭な型アサーション)を書かざるを得なくなる。

さらに、次のようなネストした設定オブジェクトを想像してほしい。

type DeepOptions = {
timeout: number;
retry: {
attempts: number;
backoff: ‘linear’ | ‘exponential’;
};
};

ここで `Partial` を使うと、`retry` ごと上書きすることになり、`attempts` だけ変えて `backoff` はデフォルトを維持したい、というユースケースで完全に破綻する。呼び出し側が `retry: { attempts: 5 }` と書いた瞬間、コンパイルエラーになるか、実行時に `backoff` が `undefined` になってランタイムエラーの引き金になる。

我々が目指すべきは、「トップレベルだけでなく、必要に応じてディープな部分まで型安全に部分上書きでき、かつデフォルト値が絶対に保証されている(`undefined` が入り込む余地のない)」状態だ。

—

解決策:Mapped Types と 型ガードを駆使した堅牢なマージ戦略

ここからは、実際のプロダクションコードでそのまま使える洗練された実装パターンを見ていく。

今回は、実務で最も要望の多い「単層の堅牢なマージ」に加え、一歩進んだ「ディープマージに対応する型定義」の核心に迫ろう。

1. 完璧なデフォルトマージ・ユーティリティの実装

まずは、TypeScriptの型推論の力を極限まで引き出した、設定マージのベースとなるコードだ。

/

  • 厳格な設定オブジェクトの型定義

/
interface AppConfig {
endpoint: string;
timeout: number;
headers: Record;
debugMode: boolean;
}

/

  • デフォルト設定(完全な型を持つ)

/
const DEFAULT_CONFIG: AppConfig = {
endpoint: ‘https://api.example.com/v1’,
timeout: 3000,
headers: {
‘Content-Type’: ‘application/json’,
},
debugMode: false,
};

/

  • 型安全な設定マージ関数
  • @param userConfig 呼び出し側から渡される部分的な設定
  • @returns 完全に解決された(undefinedが存在しない)AppConfig

/
function resolveConfig(userConfig?: Partial): AppConfig {
if (!userConfig) {
return { …DEFAULT_CONFIG, headers: { …DEFAULT_CONFIG.headers } };
}

return {
// プリミティブはスプレッドで安全に上書き
…DEFAULT_CONFIG,
…userConfig,
// オブジェクトや配列などの参照型は、シャローコピーによる汚染を防ぐためディープにマージする
headers: {
…DEFAULT_CONFIG.headers,
…(userConfig.headers ?? {}),
},
};
}

// ==========================================
// 使用例
// ==========================================

// 1. 引数なし(デフォルトが適用される)
const config1 = resolveConfig();
// 型: AppConfig, 実行結果: すべてデフォルト値

// 2. 部分的な上書き(timeoutとdebugModeだけ変更)
const config2 = resolveConfig({
timeout: 10000,
debugMode: true,
});
// 型: AppConfig, 実行結果: endpointとheadersはデフォルト、他は上書き

このコードの優れている点(アーキテクチャの視点)

1. 戻り値の型が `Partial` ではなく `AppConfig` であること
関数内部で `undefined` の可能性を完全に排除し、呼び出し元には「絶対に不備のない完全な設定オブジェクト」を返却している。これにより、以降の処理でオプショナルチェイニング(`?.`)や冗長な存在チェックを書く必要がなくなる。
2. 参照型の汚染(Mutation)を防いでいる点
単純な `{ …DEFAULT_CONFIG, …userConfig }` だと、`headers` のようなオブジェクトは参照渡しになり、別の場所で設定を変更されたときにデフォルト値自体が書き換わるバグ(ミューテーション・バグ)を生む。`headers` を明示的にマージすることで、イミュータビリティを担保している。

—

さらなる高みへ:ネストした設定を完璧に部分上書きする「DeepPartial」の罠と処方箋

「いや、うちの設定はもっと深くて複雑なんだよ」というシニアエンジニアのために、`DeepPartial` を用いた高度なアプローチについても触れておこう。

よくある実装として、以下のような再帰的型定義がネットに転がっている。

// よくある(だが少し物足りない)DeepPartial
type DeepPartial = {
[K in keyof T]?: T[K] extends object ? DeepPartial : T[K];
};

これを使うと、ネストしたオブジェクトも `Partial` にできる。しかし、これをそのままデフォルト値とマージしようとすると、TypeScriptのコンパイラは「本当にすべて埋められたか」を静的に保証できなくなるため、マージ関数側でスマートな型アサーションや、型ガード(Type Predicate)を組み合わせる必要がある。

プロダクションコードでは、次のように「マージ関数の戻り値型」を厳格に縛るのがベストプラクティスだ。

// 厳密なネスト構造を持つ設定
type DeepConfig = {
server: {
host: string;
port: number;
};
security: {
cors: boolean;
tokenTTL: number;
};
};

const DEFAULT_DEEP_CONFIG: DeepConfig = {
server: { host: ‘localhost’, port: 8080 },
security: { cors: true, tokenTTL: 3600 },
};

// ユーティリティ: ディープマージを行う関数(実装は簡易化のためロジックを凝縮)
function mergeDeepConfig(userConfig?: DeepPartial): DeepConfig {
return {
server: {
…DEFAULT_DEEP_CONFIG.server,
…(userConfig?.server ?? {}),
},
security: {
…DEFAULT_DEEP_CONFIG.security,
…(userConfig?.security ?? {}),
},
};
}

ここまで設計されていれば、フロントエンドのコンポーネントにおける `theme` や `options` の設計、あるいはNode.jsの環境変数と設定ファイルの統合レイヤーにおいて、型安全性と開発者体験(DX)の妥協なき両立を実現できる。

—

テクニカルリードからの総括

型定義とは単なる「エラーを防ぐためのボルト」ではない。「コードの意図をコンパイラとチームメンバーに正確に伝え、実行時の予測不可能性をゼロにするための強力な契約(Contract)」である。

  • デフォルト値を持つ設定オブジェクトには、安易に `Partial` を垂れ流すな。
  • 関数境界(Boundary)の入出力で型を解決し、内部や呼び出し元には常に「完成された型(`T`)」を返せ。
  • 参照型のオブジェクトはシャローコピーの罠に気をつけ、イミュータブルにマージせよ。

このパターンをマスターすれば、君の書くコードから「設定値の欠落による undefined エラー」は完全に根絶されるはずだ。
次のコードレビューでは、もっと美しい型定義を見せてくれ。期待している。

タイトルとURLをコピーしました