コードレビューをしていて、最も頻繁に遭遇する「TypeScriptの型システムの深淵を誤解している例」の一つが、コールバック関数の型定義における `void` の扱いだ。
例えば、次のようなコードを書いたことはないだろうか。
// 良くあるイベントハンドラーの型定義
type OnClick = (event: MouseEvent) => void;
// 実際に渡す関数
const handleClick = (e: MouseEvent): string => {
console.log(‘clicked’, e);
return ‘some-id’; // 戻り値を返す
};
// ❌ エラーになると勘違いして避けて通る、または無駄に型キャストする
const element: OnClick = handleClick;
驚くべきことに、最後の代入はTypeScriptでは完全に合法であり、エラーにならない。
「待て、`void` を返すはずの型に、`string` を返す関数を代入して型安全なのか?」
そう思った君は、TypeScriptの型システムの「関数型の部分型関係(Subtyping)」と「変農性(Variance)」の仕様の核心を見落としている。
今回は、この `void` の柔軟な解釈の背後にあるコンパイラの意図を解き明かし、実務のフロントエンド開発や非同期API連携において「バグを踏まないための堅牢な設計パターン」を伝授しよう。
—
なぜ `void` を期待する場所に値回帰関数を渡せるのか?
TypeScript(およびJavaScript)のランタイム、そして型システムにおいて、「何も返さないことを期待している(`void`)文脈に、値を返す関数を渡すこと」は、実用上極めて安全である。
コンパイラはこの挙動を意図的に許容している。理由は単純だ。呼び出し側は戻り値を無視するからである。
function executeCallback(callback: () => void) {
// 呼び出し側は戻り値を受け取る変数を用意していないし、利用もしない
callback();
}
// 戻り値として string を返す関数を渡しても、呼び出し側には何の影響もない
executeCallback(() => “discard me”);
もしこれが禁止されていたらどうなるか?
例えば、Arrayの `forEach` メソッドに `Array.prototype.map` のコールバックや、返り値を持つ既存のユーティリティ関数を渡すたびに、わざわざラッパー関数を書く必要が生じる。
// 厳密にやりすぎると、こんなボイラープレートが必要になる
items.forEach(item => {
processItem(item); // processItem が boolean を返すとエラーになるため、void に潰す必要がある
});
開発者の生産性を不当に落とさないために、TypeScriptの型システムはコールバックの戻り値に関して「返り値の型が `void` である場所には、任意の型の値を返す関数を代入可能」という寛容なルール(構造的型付けの恩恵)を採用している。
—
⚠️ 現場で即死する「危険な落とし穴」
しかし、この仕様にはコンパイラが守りきれないランタイムの罠が潜んでいる。ここを理解していないと、コードレビューで容赦なく差し戻されることになる。
問題は、「意図せず戻り値を捨ててしまうことによるバグ」だ。
interface AsyncOperation {
run(): Promise
}
// 「非同期処理を実行し、完了したらコールバックを呼ぶ(返り値は不要)」という設計のつもり
type LegacyHandler = (status: string) => void;
const processAndNotify = async (handler: LegacyHandler) => {
const status = await fetchStatus();
// 意図:handler にステータスを伝えるだけ
// 現実:もし handler が「処理継続すべきか」の boolean を返す仕様に改修されたら…?
handler(status);
};
ここで、将来的に `handler` が `boolean`(続行すべきか否か)を返すように仕様変更されたとしよう。
const strictHandler = (status: string): boolean => {
if (status === ‘ERROR’) {
alert(‘Stop!’);
return false; // 処理をストップしたい
}
return true;
};
// 呼び出し側(processAndNotify)は戻り値を完全に無視して捨てているため、
// strictHandler が false を返したとしても、プログラムは止まらずに次の処理へ進んでしまう!
processAndNotify(strictHandler);
コンパイラはエラーを出さない。なぜなら `void` の文脈に `boolean` を返す関数を代入することは仕様上許可されているからだ。しかし、ビジネスロジックとしては致命的なサイレントバグが生まれる。
—
プロダクションコードにおける堅牢な設計パターン
では、この柔軟性を活かしつつ、意図しないバグを防ぐにはどうすればよいか?
チーフアーキテクトである私が現場に強制している、モダンで保守性の高い設計パターンを提示する。
パターン1: コールバックが「意図的に戻り値を求めているか」を明示する
もしコールバックの戻り値がビジネスロジックに影響を与える(例えば、アクションの継続判定など)のであれば、型定義側で絶対に `void` を使ってはいけない。 `unknown` または具体的な型を指定し、呼び出し側で戻り値をハンドリングすることを強制する。
// ❌ 悪手:戻り値があるかもしれないのに void にしている
type BadCallback = (data: Data) => void;
// ⭕️ 正解:戻り値の有無や意味を型で明確に制限する
// 「何かを返してもいいが、呼び出し側はそれを処理する責任を持つ」場合
type StrictCallback
// もしくは、明示的に「何も返さないこと」を保証させたい、あるいは
// 関数のシグネチャの厳密性を保ちたい場合(下記参照)
パターン2: `ExactOptionalPropertyTypes` との組み合わせによる厳密化
ReactのコンポーネントPropsや、非同期APIのオプションオブジェクトでコールバックを受け取る際、過度な柔軟性を排除したい場面がある。
type EventHandler = {
// 戻り値に厳密に void を強制したい(余計な値を返させたくない)場合、
// 文脈によってはコールバックの割り当てを制限する必要がある
onComplete: () => void;
};
ここで、TypeScriptの言語仕様において、「変数に直接インラインで関数を代入する場合」と「一度変数に代入してから渡す場合」で `void` の挙動が異なる点に注意してほしい。
const myService = {
onComplete: () => {
return 42; // インライン定義では、オブジェクトの型が { onComplete: () => number } に推論されることがある
}
};
// 型定義が onComplete: () => void の場所に関数丸ごと代入する場合
const handler: EventHandler = myService; // おっと、これはOKだが…
実は、`() => void` 型の変数には、何を返そうが代入できる。
しかし、もし「絶対に何も返さない関数(副作用のみを実行する関数)」であることを型レベルで厳格に担保したい、あるいはミスを防ぎたい場合は、次のようにアロー関数の本体をブロックで囲むテクニックが有効だ。
// ブロック {} を使うと、明示的に return を書かない限り戻り値は void になる
const safeHandler = () => {
console.log(“Side effect only”);
// return 42; ⬅️ これを書くとコンパイルエラーになる(Type ‘number’ is not assignable to type ‘void’)
};
あえてブロック構文(`{ … }`)を使用させるコード規約を設けることは、ジュニアエンジニアのうっかりミスを防ぐ上で非常に効果的なプラクティスである。
—
究極のプロダクションコード例:非同期API連携フック
最後に、フロントエンドのReactやVueなどのコンポーネント設計でそのまま使える、極めて堅牢なカスタムフックの型設計をお見せしよう。
ここでは、副作用(コールバック)を受け取りつつ、その戻り値の型安全性をコントロールするモダンな実装だ。
/
- API通信の状態と、完了時のコールバックを安全に管理する型定義
/
type AsyncActionStatus = ‘idle’ | ‘loading’ | ‘success’ | ‘error’;
// コールバックが「何らかの結果を返す可能性」を許容しつつ、
// 呼び出し側でその結果を適切にハンドリングするジェネリック設計
interface UseApiActionOptions
onSuccess?: (data: TResult) => void; // 戻り値は void だが、任意の関数を渡せる
onError?: (error: unknown) => void;
}
/
- 堅牢な非同期アクション実行フック
/
export function createApiAction
apiCall: () => Promise
transform?: (data: TData) => TResult
) {
return async (options?: UseApiActionOptions
try {
const rawData = await apiCall();
const processedData = transform ? transform(rawData) : (rawData as unknown as TResult);
// ここで onSuccess を呼ぶ。
// onSuccess にはどんな関数でも渡せるが、戻り値は捨てられる仕様。
// もし結果を利用したいなら、このスコープ内で明示的に受け取る設計に昇華させるべき。
options?.onSuccess?.(processedData);
return rawData;
} catch (error) {
options?.onError?.(error);
throw error;
}
};
}
// ==========================================
// 💡 実際の使用例
// ==========================================
interface User {
id: string;
name: string;
}
// APIのモック
const fetchUser = async (): Promise
// 実行
const handleGetUser = createApiAction(fetchUser, (user) => user.name);
// 使う側
await handleGetUser({
onSuccess: (name) => {
// name は string型であることが完全保証されている
// 仮にこのコールバックが何かを返しても、TypeScriptは文句を言わないし安全に動作する
console.log(`User loaded: ${name}`);
},
onError: (err) => {
console.error(‘Failed to load’, err);
}
});
—
アーキテクトからの提言
TypeScriptの `void` の柔軟な解釈は、開発者のボイラープレートを減らすための「言語側の優しさ」である。しかし、この優しさを盲信し、副作用と戻り値の境界を曖昧にした設計を放置すると、大規模開発において追跡困難なバグの温床となる。
- コールバックが単なる「副作用の通知(イベントリスナー等)」であれば、`void` の柔軟性に甘えてもよい。
- コールバックの戻り値がアプリケーションの制御フロー(継続判定やデータ加工)に影響を与えるのであれば、`void` を捨て、明示的なジェネリック型やユニオン型で厳密に縛るべきだ。
型システムの裏側にあるコンパイラの挙動を完璧に掌握し、チーム全体のコード品質をネクストレベルへと引き上げてほしい。