【実務・中級編】関数型における「void」と「undefined」の戻り値の差異と呼び出し元への影響 – TypeScript コア・型システムの基礎解析バイブル

TypeScriptの型システムにおいて、`void` と `undefined` は初学者の多くが「何となく同じようなもの(何も値を返さない)」として扱いがちなプリミティブです。しかし、コンパイラがこれらをどう解釈し、型安全性にどう寄与しているのかを深く理解していなければ、実務の現場で予期せぬバグや、リファクタリング耐性の低い脆いコードを生み出す原因になります。

今回は、特にコールバック関数の戻り値における `void` と `undefined` の挙動の差異に焦点を当て、TypeScriptが隠し持つ「寛容性の仕様」と、それを利用した堅牢なコンポーネント設計・API連携の極意を伝授します。

—

1. なぜ `void` を期待するコールバックに値を返す関数を渡してもエラーにならないのか?

まずは、以下のコードを見てほしい。

type EffectCallback = () => void;

// 1. 文字列を返す関数
const fetchAndLog = (): string => {
console.log(“fetching…”);
return “data”;
};

// 2. voidを期待する型に、値を返す関数を代入
const executeEffect = (callback: EffectCallback) => {
callback(); // 実行時の戻り値はどう扱われる?
};

executeEffect(fetchAndLog); // ⚠️ コンパイルエラーにならない!

`EffectCallback` は「何も返さない(`void`)」ことを契約しているはずなのに、実際には文字列を返す `fetchAndLog` を渡しても、TypeScriptコンパイラは平然とこれをパスします。

「バグではないのか?」と思われるかもしれませんが、これはTypeScriptの意図された言語仕様(Substitutability / 代入可能性)です。

コンパイラ内部での型評価の真実

TypeScriptにおいて、戻り値の型が `void` である関数型は、「何を返してもよい(返り値は無視される)」 という特殊な許容モードに入ります。

これは、JavaScriptエコシステムにおける現実的な要請に基づいています。例えば、`Array.prototype.forEach` のコールバックは `void` を返すべきとされていますが、開発者がうっかり `Array.prototype.map` のチェインのノリで `arr.forEach(item => item.id)` のように、式の結果(数値や文字列)を返してしまうことは日常茶飯事です。

もしここでTypeScriptが厳格に「`undefined` 以外を返してはならない」とエラーを出したらどうなるか? 既存の無数のユーティリティ関数やサードパーティライブラリをコールバックに渡すたびに、無駄なアロー関数のラップ(`() => { foo(); }`)を書か迫られ、開発体験は最悪のものになっていたでしょう。

—

2. `void` と `undefined` の決定的な違い

では、戻り値の型アノテーションに `undefined` を明記した場合はどうなるでしょうか?

type StrictCallback = () => undefined;

const fetchAndLog = (): string => “data”;

// ⚠️ ここでコンパイルエラーが発生する!
// Type ‘() => string’ is not assignable to type ‘() => undefined’.
const executeStrict = (callback: StrictCallback) => {
callback();
};

executeStrict(fetchAndLog);

`undefined` は、「厳密に `undefined` というプリミティブ値のみを返すこと」を強制する型です。そのため、値を返す可能性のある関数を代入すると、コンパイラは即座に型ミスマッチを検出します。

比較まとめ

| 戻り値の型 | 意味 | 呼び出し側・代入側の挙動 |
| :— | :— | :— |
| `void` | 「返り値は利用しない(意図しない)」 | どんな値を返す関数でも代入可能(返り値は破棄される) |
| `undefined` | 「厳密に `undefined` という値を返す」 | `undefined` を返す関数しか代入できない |

—

3. 実務で直面する危険な罠:コールバックの「戻り値の看過」

この `void` の寛容性は、便利である一方で、フロントエンドの非同期処理やイベントハンドラー設計において深刻なバグを引き起こす温床になります。

以下のコードを見てください。あなたはコードレビューでこの問題を見抜けるでしょうか?

interface UseFormOptions {
// 送信完了時に呼ばれるコールバック(voidを期待)
onSubmitSuccess?: (response: ApiResponse) => void;
}

function handleFormSubmit(options: UseFormOptions) {
const response = api.submit();

// 意図:単にコールバックを実行するだけ
options.onSubmitSuccess?.(response);
}

ここに、以下のようなコードを書いた開発者がいました。

// 開発者Aのコード
handleFormSubmit({
onSubmitSuccess: (res) => {
// 画面をリロードする関数を返すつもりが、
// アロー関数の省略構文で「リロード関数の実行結果(void)」ではなく
// 「関数そのもの」を返してしまっている!
return window.location.reload(); // window.location.reload() は void を返す
}
});

この例ではたまたま `void` 同士なので問題ありませんが、もしコールバックの戻り値を使って何らかの処理チェインを組もうとした瞬間、`void` によって返り値が握りつぶされているため、デバッグが困難なサイレントバグに発展します。

—

4. 堅牢な設計パターン:正確な型制約のコントロール

テクニカルリードとして、チーム全体のコードベースでこのような曖昧さを排除し、堅牢なコンポーネント設計を行うためのアプローチを提示します。

パターンA: 「本当に何も返してほしくない」場合は `void` を厳格に扱うジェネリクス・ユーティリティ

もしあなたがライブラリ作者や共通基盤の設計者であり、「コールバック内でうっかり値を返したらバグる」ようなクリティカルな処理(例:トランザクション制御やミドルウェアのフック)を作っている場合、`void` では緩すぎます。

その場合は、関数型をラップして「返り値を強制的に捨てさせる」あるいは「厳密に型を縛る」ユーティリティ型を設計します。

// 戻り値が何であれ、強制的に void として扱うラッパー
type StrictVoidCallback = (…args: TArgs) => void;

// もしくは、返り値の存在を型レベルで警告・制限したい場合
// (※ strictFunctionTypes の有効化が前提)

さらに、tsconfig.json では必ず以下のフラグを有効にしてください。

{
“compilerOptions”: {
“strict”: true,
“strictFunctionTypes”: true // 関数型の引数・戻り値の変群性(contravariance/covariance)を厳密化
}
}

パターンB: プロダクションで使える堅牢なイベント・フック設計例

実務のフロントエンド(React/Vueなどのコンポーネント設計)において、非同期イベントハンドラーを定義する際のベストプラクティスコードを示します。

// — 堅牢なAPI設計の模範コード —

type AsyncVoidHandler = (data: T) => Promise | void;

interface ActionButtonProps {
/

  • クリック時の非同期アクション。
  • 呼び出し元がうっかり値を返却してロジックが迷子になるのを防ぐため、
  • 明示的に Promise または void を強制する。

/
onAction: AsyncVoidHandler;
data: TData;
}

// コンポーネント側の実装
export function ActionButton({ onAction, data }: ActionButtonProps) {
const handleClick = async () => {
try {
// 戻り値を受け取らないことを型レベルで担保
await onAction(data);
} catch (error) {
console.error(“Action failed:”, error);
}
};

return (

);
}

// — 呼び出し側の正しい利用例 —
// OK: 単なる処理の実行(副作用のみ)
const App = () => (
{
// 副作用を実行するだけ。returnは書かない。
analytics.track(“click”, item.id);
}}
/>
);

この設計であれば、`onAction` のコールバック内で意図せぬデータをリターンしようとした際、開発者が意図しない型(例えば `boolean` や `string`)を返してしまった場合に、TypeScriptが即座にエラーを検知し、「このコールバックは値を返すべきではありません」と開発者に教えることができます。

—

結び:型システムを味方につけるために

TypeScriptの `void` は、JavaScriptの歴史的背景(関数は何も返さなければ `undefined` を返す)と、開発者の実用的な利便性を両立させるための「優しい免罪符」です。

しかし、大規模なプロダクションコードや、厳密なデータフローが求められるアーキテクチャにおいては、この「優しさ」が逆にバグの温床になります。
「ここは副作用だけで値を返す必要がないから `void` でいいや」と安易に書くのではなく、「この関数は将来にわたって値を返すべきではないか?」「呼び出し元が返り値を誤認する余地はないか?」という視点を持つこと。

それこそが、単に動くコードを書くエンジニアから、保守性と堅牢性を支配する「真のシニア・アーキテクト」への境界線です。明日のコードレビューから、ぜひこの視点を取り入れてみてください。

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