戻り値の `void` と `undefined`:コンパイラ型評価とランタイム破綻の境界線
TypeScriptの型システムにおいて、`void` と `undefined` は初学者の多くが混同しがちなプリミティブである。しかし、V8などのJavaScriptエンジン内部挙動、TypeScriptコンパイラ(tsc)の型推論メカニズム、そして高階関数における型安全性の観点から見れば、この二者は完全に異世界の概念である。
本稿では、戻り値が `void` である関数においてなぜ `undefined` を返すことが型システムおよび実行時において危険なのか、その低レイヤの真実を解き明かす。
—
1. コンパイラ視点:`void` は「無関心」、`undefined` は「値の存在」
まず、TypeScriptの型定義におけるセマンティクスを再確認する。
- `undefined`: プリミティブ値の一つであり、JavaScriptの仕様上、未初期化の変数や値が存在しないことを示す具体的な「値」である。
- 導出された `void`: 関数が「戻り値を意図的に返さない」ことを示すトップレベルの型アノテーションである。
TypeScriptの型チェッカー(`checker.ts`)において、戻り値の型が `void` の関数は、「呼び出し元が戻り値を利用しない(捨て去る)」という契約を結ぶ。
// コンパイル時の型契約の乖離
function executeLog(message: string): void {
console.log(message);
// 型エラーにならない(しかしアーキテクチャ上の地雷)
return undefined;
}
一見、`undefined` は値を持たないため `void` と互換性があるように見える(実際、`strictNullChecks` が無効な場合や、特定の関数型代入性ルールにおいては許容されることがある)。しかし、これがコールバックや高階関数の世界に入った瞬間、型安全性の防壁が崩壊する。
—
2. 高階関数における型汚染:なぜ `void` に `undefined` を混ぜてはいけないのか
次のコードを見てほしい。Array.prototype.forEach や、カスタムのイベントエミッターを想像してほしい。
type EffectCallback = () => void;
// 呼び出し側(フレームワーク層)
function runEffect(callback: EffectCallback): void {
// 戻り値は「捨てられる」ことが前提
const result = callback();
// もしここで result を何かに使おうとしたら?
// 実際には void なので TypeScript は型エラーにするが…
}
ここで、コールバックを渡す側が「何も返さない」代わりに明示的に `undefined` を返したり、値を持つ式(例えば `Array.prototype.push` の戻り値など)をそのまま流し込んだとする。
const items: number[] = [];
// Array.prototype.push は「追加後の配列の長さ(number)」を返す
// しかし、シグネチャ上 () => void を要求する場所にこれを渡すと何が起きるか?
const badCallback: EffectCallback = () => items.push(42);
// TypeScriptの「戻り値のBivariance(双方向性)」または「割愛ルール」により、
// number を返す関数が void を要求する場所に代入可能になってしまうケースがある。
コールバックの戻り値破綻
`void` を返す関数型に `( ) => number` や `( ) => undefined` が代入可能なとき、呼び出し元は「戻り値など存在しない」と仮定してコードを生成する。しかし、実行時には値が返されてしまっている。
これが大規模な非同期パイプラインや、RxJSのようなリアクティブ・ストリームの内部で起きた場合を想像してほしい。イベントループのマイクロタスク・キューにおいて、意図せぬ戻り値がメモリ上に残り続け、ガベージコレクタの最適化パスを阻害する要因になり得る。
—
3. ランタイムとV8エンジン最適化:隠れたパフォーマンス劣化
JavaScriptエンジン(V8など)は、JITコンパイル(IgnitionとTurboFan)の過程で、関数の形状(Hidden Class / Shape)や戻り値の推論を行っている。
- `void` を返す関数: エンジンは「この関数は値を返さない(寄存器に値をロードする必要がない)」と判断し、インライン化(Inlining)やDead Code Elimination(DCE)の最適化を aggressively に適用できる。
- `undefined` を返す関数: 明示的に `return undefined;` が書かれている場合、エンジンはローカル変数やレジスタに `undefined`(またはジャンプ先でのポインタ解決)をロードする命令を生成せざるを得ないケースがある。
極限までパフォーマンスが要求されるエッジコンピューティングやリアルタイム・レンダリング・パイプラインにおいて、この無駄な `undefined` の往復は、ホットパス(Hot Path)におけるCPUキャッシュ効率を静かに悪化させる。
—
4. 正しい設計:防壁を構築するTypeScriptコード
この問題を完全に封じ込めるための、シニアエンジニアリングとしての実践的なアプローチを示す。
① `noImplicitReturns` と `noUncheckedIndexedAccess` の徹底
`tsconfig.json` では、コンパイラの網を極限まで厳しく張る。
{
“compilerOptions”: {
“strict”: true,
“noImplicitReturns”: true,
“noUncheckedIndexedAccess”: true,
“exactOptionalPropertyTypes”: true
}
}
`noImplicitReturns` を有効にすることで、`void` 以外の戻り値を持つ関数でパスの途中でreturnが漏れている場合にコンパイルエラーになるだけでなく、`void` を明示した関数内でうっかり `return;` 以外の値を返すことを防ぐ。
② 戻り値型における `void` と `undefined` の厳密な使い分け
// 【NGな設計】戻り値が曖昧
function processStreamData(data: string): void | undefined {
if (!data) return undefined; // 悪臭を放つアンチパターン
console.log(data);
}
// 【Goodな設計】
// 1. 値が存在しない、または失敗する可能性があるなら、明確に `undefined` を型に含める
function safeFindData(id: string): Data | undefined {
const item = database.get(id);
return item ? item : undefined;
}
// 2. 副作用のみを実行し、呼び出し元に戻り値を一切期待させない場合は、完全な `void`
function logTelemetry(event: TelemetryEvent): void {
analytics.send(event);
// return は一切書かない(あるいは制御フロー的に不要)
}
③ `never` との組み合わせによる完全な網羅性チェック
イベントハンドラーやディスパッチャーの型定義では、戻り値に `void` を強制することで、誤ってデータを伝搬させるバグをコンパイルタイムで根絶する。
type EventHandler
const handleClick: EventHandler
// コンパイラが「この関数は何も返してはならない」と強制する
// 誤って `return event.clientX;` などと書いた瞬間にエラーとなる
trackClick(event.clientX, event.clientY);
};
—
結び:型は「仕様書の防壁」である
TypeScriptにおける型システムは、単なるIDEの補完ツールではない。それは、「ランタイムの混沌に対して人間が築き上げる唯一の防壁」である。
`void` と `undefined` の差異を曖昧にすることは、その防壁に自ら小さな亀裂を入れることに等しい。プロパティの有無、非同期処理のチェイン、そして高階関数の安全性を極限まで高めたいのであれば、「何もしないこと」を表す `void` に、決して「何もないという値」である `undefined` を持ち込んではならない。
コードを書くその瞬間から、コンパイラが生成する機械語の息吹を感じ取れ。それこそが、真のTypeScriptマイスターの境地である。