【実務・中級編】関数の引数における「Nullish Coalescing」とデフォルト引数の型推論の優先順位 – TypeScript コア・型システムの基礎解析バイブル

開発チームの皆さん、お疲れ様です。テクニカルリードの私だ。

今日のコードレビューで、また散見された。「おや?」と思うような、引数の初期値設定に関するアンチパターンがな。
「動くからいいや」で書かれたコードは、TypeScriptの高度な型推論エンジンを骨抜きにし、将来のリファクタリングで必ず足元をすくわれる。

今回は、関数の引数における「Nullish Coalescing(??)」と「デフォルト引数(`=」)の型推論の優先順位について、コンパイラの挙動から徹底的に紐解いていこう。

なぜその書き方が危険なのか、そしてプロダクションコードでどう書くべきなのか。実例を交えて伝授する。

—

1. コンパイラは何を見ているか:デフォルト引数 vs Nullish Coalescing

まずは、以下の2つの関数を見比べてほしい。どちらも「値が渡されない、あるいは`null`/`undefined`の場合にデフォルト値を適用する」という一見同じ目的で作られている。

// パターンA: デフォルト引数を使う方法
function processUserA(id: string, role: string = ‘guest’) {
// …
}

// パターンB: Nullish Coalescing を関数内部で使う方法
function processUserB(id: string, role?: string) {
const resolvedRole = role ?? ‘guest’;
// …
}

「大差ないじゃないか」と思ったそこのあなた。甘い。
ここには型推論の決定的な違いが存在する。

TypeScriptのコンパイラ(型チェッカー)は、デフォルト引数(`role = ‘guest’`)を検知した瞬間、「この引数には`undefined`が入り得るが、呼び出し側が省略した場合は内部で非undefinedの型に昇格させる」という特殊なアノテーションを自動付与する。

一方、パターンBの `role?: string` は、型としては `string | undefined` のままだ。関数内部で `??` を使ってフォールバックしていても、コンパイラの制御フロー分析(Control Flow Analysis)が複雑化すると、型安全性の担保にボロが出る。

—

2. 現場で起きる悲劇:オプショナル引数と `??` の悪夢

実務でよくある、APIレスポンスの設定をマージするユーティリティ関数を考えてみよう。ここで「デフォルト引数」と「Nullish Coalescing」の優先順位や挙動の差異を理解していないと、次のようなバグを生む。

type Config = {
timeout: number;
retries: number;
debugMode: boolean;
};

// ❌ 危険な実装パターン
function initializeConfig(userConfig?: Partial) {
// デフォルト値をごちゃ混ぜにしようとして破綻する例
const config: Config = {
timeout: userConfig?.timeout ?? 3000,
retries: userConfig?.retries ?? 3,
debugMode: userConfig?.debugMode ?? false,
};
return config;
}

このコード、一見問題なく動くように見える。しかし、「呼び出し側が `undefined` ではなく、意図的に `null` や予期せぬ型を渡してきた場合」や、将来的に `userConfig` 自体のデフォルト値を関数の引数レベルで定義しようとした途端に、型と実行時の挙動が乖離し始める。

特に、「Falsyな値(`0`, `false`, `””`)」を許容するプロパティにおいて、`||` 演算子と `??` 演算子の違いを混同しているエンジニアが多すぎる。
`??` は `null` と `undefined` のみを弾くため、`0` や `false` は正しく通る。しかし、引数のデフォルト値(`= `)と `??` を二重にネストさせたり、不適切に併用したりすると、TypeScriptの型推論はどの型を正とすべきか迷子になる。

—

3. プロダクションコードにおける模範解答:究極の型安全パターン

では、テクニカルリードとして我々が書くべき「美しく、堅牢なコード」とは何か。
答えは、「関数の引数シグネチャでデフォルト値を完結させ、内部のロジックで不要なフォールバックを排除する」ことだ。

以下のコードを見てほしい。これが、TypeScriptの型システムを100%信頼し、バグをコンパイル時にねじ伏せるベストプラクティスだ。

/

  • 堅牢な設定初期化関数
  • 引数の分割代入とデフォルト値の機能を最大限に活用する

/
type ConnectionOptions = {
endpoint: string;
timeout?: number;
retries?: number;
protocol?: ‘http’ | ‘https’;
};

// デフォルト値を型定義から明確に導出するための定数
const DEFAULT_OPTIONS = {
timeout: 5000,
retries: 3,
protocol: ‘https’,
} as const;

export function createConnection(options: ConnectionOptions) {
// 分割代入時のデフォルト値構文を使用する
// ここで TypeScript は、推論された型から undefined を綺麗に剥ぎ取る
const {
endpoint,
timeout = DEFAULT_OPTIONS.timeout,
retries = DEFAULT_OPTIONS.retries,
protocol = DEFAULT_OPTIONS.protocol,
} = options;

// この時点で、timeout, retries, protocol は「絶対にundefinedにならない」型保証を得る
// 冗長な ?? や || は一切不要になる。

return {
connectUrl: `${protocol}://${endpoint}`,
timeout,
retries,
};
}

// — 使用例 —
// 1. 最小限の引数
const conn1 = createConnection({ endpoint: ‘api.example.com’ });
// 2. 一部上書き
const conn2 = createConnection({ endpoint: ‘api.example.com’, timeout: 10000 });

この設計が優れている理由

1. 型と実態の完全な同期:
分割代入のデフォルト値(`timeout = DEFAULT_OPTIONS.timeout`)を使うことで、TypeScriptは関数のスコープ内における変数の型から `undefined` を完全に排除する。後続の処理で非nullアサーション(`!`)や冗長な `??` を書く必要がなくなる。
2. ランタイムとコンパイル時の二重の安全性:
`as const` を使った定数オブジェクトをデフォルト値のソースとすることで、マジックナンバーを排除しつつ、型定義の厳密性を維持している。
3. 可読性の向上:
「どこで値が補正されているのか」を探す旅に出る必要がない。関数の入口(シグネチャ / 分割代入部)を見れば、すべてのデフォルト値が一目で把握できる。

—

4. リードからの総括

TypeScriptの型システムは、私たち開発者の「めんどくさい」を補うためにあるのではない。「実行時エラーの可能性をコンパイル時にゼロにするための強靭な防壁」だ。

関数の引数におけるデフォルト値とNullish Coalescing(`??`)は、どちらも「値がないときのフォールバック」という似た役割を持つがゆえに、安易に混在させるとコードの意図を曖昧にし、型推論の精度を落とす。

  • 引数の必須化・デフォルト値の設定: 原則として関数の引数定義または分割代入のデフォルト値で行う。
  • Nullish Coalescing(`??`): APIレスポンスや外部から流入する、型が信用できない「生のデータ」を安全にハンドリングするために限定して使う。

この原則を守るだけで、君たちの書くコードの質は一段階跳ね上がる。
次のコードレビューでは、この視点が反映されていることを期待している。実装頼んだぞ!

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