【実務・中級編】引数に渡す「コールバック関数」の戻り値がvoidである場合の挙動の注意点 – TypeScript コア・型システムの基礎解析バイブル

フロントエンドのコードレビューをしていて、最もゾッとする瞬間の一つがこれだ。

// 一見、何の変哲もないイベントハンドラーの登録処理
element.on(‘click’, () => {
// 処理の途中で何かを返してしまっている
return fetchSomething();
});

「おや、このコールバックは `void`(戻り値なし)を期待しているはずなのに、なぜ `Promise` を返す関数が型エラーなくスルーされているんだ?」

TypeScriptを実務で深く使い込んでいるエンジニアなら、一度はこの奇妙な仕様に直面し、冷や汗をかいたことがあるはずだ。
今回は、このTypeScriptの仕様の裏側にある型システムの思想と、それをコンパイル時に完全にハックしてバグを根絶する「堅牢な設計パターン」を伝授しよう。

—

なぜ「voidを期待する場所」に値が返せるのか?

TypeScriptの型システムには、JavaScriptの動的な振る舞いを安全かつ実用的に扱うための「緩衝材」がいくつか存在する。その代表格が 「関数型のサブタイピング(Bivariance / Parameter Bivariance / Assignability of Return Types)」 だ。

TypeScriptにおいて、「`void` を返すことを期待されている関数型」に対して、任意の戻り値を持つ関数を代入することは意図的に許可されている。

コンパイラは次のように考えている。
> 「呼び出し側(Consumer)は、コールバックが何を返そうが、その戻り値を一切無視して捨てるつもりで呼び出している。だから、実装側(Producer)が余分に値を返したとしても、呼び出し側には何の実害もないはずだ」

この仕様は、Arrayの `forEach` やイベントリスナーなどで非常に強力に働く。

const numbers = [1, 2, 3];

// forEach が期待するコールバックは (value: number) => void
// しかし、Array.prototype.push は新しくなった配列の長さを返す((item: number) => number)
numbers.forEach((n) => numbers.push(n 2)); // 型エラーにならない!

もし、この仕様がなければ、`forEach` の中に `push` を直に渡すだけでコンパイルエラーになり、私たちは無駄なアロー関数(`n => { numbers.push(n 2); }`)を量産させられていただろう。
言語デザインとして、これは極めて合理的な「開発者フレンドリーな妥協」である。

—

この仕様がフロントエンド開発で引き起こす「静かなる殺人バグ」

しかし、この「親切心」が、非同期処理(Promise)や状態管理が絡むモダンなフロントエンド開発においては凶器に変わる。

次のコードを見てほしい。非同期のアクションを実行するカスタムフックやユーティリティの設計を想定している。

type EffectRunner = (callback: () => void) => void;

const runEffect: EffectRunner = (cb) => {
console.log(‘Effect started’);
cb();
console.log(‘Effect finished’);
};

// 呼び出し側
runEffect(async () => {
// 非同期処理をフック内で完結させるつもりが…
await someAsyncOperation();
// 意図せず Promise が返っているが、runEffect 側は void しか受け取らないため握りつぶされる
});

ここで何が起きるか?
`runEffect` は同期的に最後まで実行され、`”Effect finished”` が出力される。しかし、その内部で呼ばれた `cb` は内部で非同期処理(`Promise`)の解決を待たずに(あるいは `await` の最初のマイクロタスクで処理を中断して)制御を返している。

「非同期処理の完了を待ってから次の処理に進みたかったのに、非同期処理が完全に宙ぶらりん(Fire-and-forget)になり、タイミングズレによる競合バグが起きた」
――現場で遭遇する「原因不明のUIのちらつき」や「データ不整合」の多くは、この `void` の罠に起因している。

—

解決策:真に「voidのみ」を強制する厳格な型定義

「コールバックの戻り値が何であれ無視する」というデフォルトの挙動をオーバーライドし、「戻り値が `void`(厳密には `undefined` や非Promise)であること」をコンパイル時に強制するにはどうすればよいか。

ここで、TypeScriptの conditional types(条件付き型)とジェネリクスの制約を組み合わせた、プロダクションレベルの堅牢な型定義テクニックを導入する。

1. 厳格なコールバックを受け入れる関数シグネチャ

/

  • 戻り値が厳密に void(または値を返さない)であることを強制する型ヘルパー

/
type StrictVoidCallback =
(…args: TArgs) => TReturn extends void ? void : never;

// または、よりシンプルに「Promiseや値を返させない」ユーティリティ
type NoInfer = [T][T extends any ? 0 : never];

// 実践的な関数定義
declare function executeSafely(
callback: () => T extends Promise ? never : void
): void;

もっとエレガントに、コールバックの戻り値の型を推論させつつ、それが `Promise` や具体的な値を返してきた場合にコンパイルエラーを吐かせるジェネリクスパターンを見てみよう。

/

  • コールバックが値を返すことをコンパイルエラーにするためのジェネリクスシグネチャ

/
export function registerCallback(
callback: () => T
): void {
// 実装
const result = callback();
if (result !== undefined) {
// 実行時セーフティネット(必要に応じて)
console.warn(‘Warning: Callback returned a value, but void was expected.’);
}
}

実際にこの関数を使ってみると、TypeScriptの挙動がどう変わるか。

// 1. 正しいケース:何も返さない(あるいは明示的に何も返さない)
registerCallback(() => {
console.log(‘Hello World’);
}); // OK

// 2. 正しいケース:return がないアロー関数(暗黙の void)
registerCallback(() => {
doSomethingSynchronously();
}); // OK

// 3. 危険なケース:値を返している
registerCallback(() => {
return 42;
// ❌ Type ‘number’ is not assignable to type ‘void’.
});

// 4. 最も危険なケース:async/await や Promise を返している
registerCallback(async () => {
await fetchUserData();
// ❌ Type ‘Promise‘ is not assignable to type ‘void’.
});

この型定義を入れるだけで、開発者がうっかり `async` 関数を渡したり、値の返却を書き忘れて式を返してしまったりした瞬間に、IDEが赤波線で警告を出してくれるようになる。

—

現場のアーキテクチャにどう組み込むか(実践パターン)

実際のプロダクトコードにおいて、この厳格な型付けをどこに適用すべきか。
代表例として、カスタムフック(Reactなど)やイベントエミッターの設計に組み込んだコードを見てほしい。

import { useEffect, useRef } from ‘react’;

/

  • 【プロダクションコード例】
  • 非同期処理の混入を防ぐ、安全なインターバル・エフェクトフック

/

// 戻り値が Promise や 値であってはならない制約を付与したコールバック型
type StrictEffectCallback = () => void;

interface UseSafeIntervalOptions {
delay: number | null;
enabled?: boolean;
}

export function useSafeInterval(
callback: StrictEffectCallback,
{ delay, enabled = true }: UseSafeIntervalOptions
): void {
const savedCallback = useRef(callback);

// コールバックの参照を常に最新に保つ
useEffect(() => {
savedCallback.current = callback;
}, [callback]);

useEffect(() => {
if (delay === null || !enabled) {
return;
}

const id = setInterval(() => {
// ここで実行されるコールバックは、絶対に予期せぬ Promise を返さないことが保証されている
savedCallback.current();
}, delay);

return () => clearInterval(id);
}, [delay, enabled]);
}

この設計がもたらす圧倒的なメリット

1. バグの早期発見: 開発者が `useSafeInterval(async () => { … }, { delay: 1000 })` と書いた瞬間、IDE上で即座にコンパイルエラーになるため、「なぜかタイマーの挙動がおかしい」というデバッグの無駄な時間をゼロにできる。
2. 保守性の向上: コードレビュー時に「ここに非同期処理を混ぜてはいけない」という暗黙の了解を、TypeScriptの型システムという「絶対的な仕様書」に置き換えることができる。
3. パフォーマンスとメモリ効率: 意図しない非同期処理の多重起動(競合状態)を防ぎ、予期せぬリソースリークを水際でブロックする。

—

チーフアーキテクトからの提言

TypeScriptの型システムは、時に私たちの開発を助けるために「寛容(Bivalent)」に振る舞う。しかし、その優しさが時として、非同期プログラミングにおける致命的な見落としを生む原因になる。

「動くからいいや」ではなく、「なぜこの型はこういう挙動をするのか」「この寛容さは自分のコードに何の恩恵をもたらし、どんなリスクを孕んでいるか」を常に問い直してほしい。

コードレビューでこの手のバグを見つけたら、ただ直すのではなく、今回紹介したような「型による強制力(Guard)」を型定義のレイヤーで組み込み、チーム全体のコードベースを次のステージへと引き上げてほしい。
君たちの書くコードが、静的解析の強烈な守護によって、より堅牢で美しいものになることを期待している。

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