※このブログではサーバー運用、技術の検証等の費用のため広告をいれています。
記事が見づらいなどの問題がありましたらContactからお知らせください。


【TypeScript】関数の呼び出しシグネチャの戻り値voidに注意

プログラミング TypeScript 型付き言語 call signature 呼び出しシグネチャ 関数

投稿日:2022年2月11日

このエントリーをはてなブックマークに追加
呼び出しシグネチャ自体はそこまで難しい内容ではないのですが、ただ一つ、返り値がvoidの時については扱い方に気を付ける必要があります。この記事では呼び出しシグネチャの返り値がvoidを指定した場合の注意点、またその解決方法について詳しく解説しています。

はじめに

この記事について

TypeScriptでは関数を扱う際に、定義する型宣言とは別に呼び出しシグネチャ(Call Signatures)というものが存在します。
呼び出しシグネチャ自体はそこまで難しい内容ではないのですが、ただ一つ、返り値がvoidの時については、扱い方に気を付ける必要があります。


この記事では呼び出しシグネチャの返り値がvoidを指定した場合の注意点、またその解決方法について詳しく解説しています。

環境

この記事のサンプルコードは以下の環境で確認しています。

  • node version 17.2.0
  • typescript version 4.5.5

参考

この記事のサンプルコードは以下で公開しています。


関数の型定義

まずは事前の知識として関数の型定義から、今回の本題である戻り値の型について簡単に確認しておきましょう。

戻り値の型について

まずは簡単な例になります。

戻り値を指定した関数
// 値を数値型で指定し、数値をreturn
function func_1(a: string): number {
    return 10;
}

// 戻り値をvoidで指定、returnを呼び出さない
function func_2(a: string): void {
}

// 戻り値をvoidで指定、returnでなにも返さない
function func_3(a: string): void {
    return;
}

戻り値として値やTypeInterfaceなどを指定した場合には、それを満たした値を必ずreturnする必要があります。戻り値としてvoidを指定した場合にはreturnを呼び出さない、または空のreturnを呼び出す必要があります。

当然これを満たさない以下のようなパターンではエラーになります。

コンパイルエラーになる関数
// 戻り値がnumberなのに何も返していない
function func_1_error_1(a: string): number {
}

// 戻り値がvoidなのに値を返す
function func_2_error_1(a: string): void {
    return 10;
}

ここまではかなり基本的な仕様ですね。
他のプログラミング言語と全く大差ない仕様になります。


呼び出しシグネチャ(Call Signatures)

基本

callbackの型を指定する場合などには、割り当てられる関数の型を定義する必要があります。
これを呼び出しシグネチャ(Call Signatures)と言います。

まずは簡単な例をみてみましょう。

呼び出しシグネチャを使った関数の例
// 呼び出しシグネチャ(オブジェクトタイプ)
type Func4 = {
    (a: string): number
}
const func_4_variable : Func4 = function func_4(a) {
    return 10;
}

// 呼び出しシグネチャ(アロータイプ)
type Func5 = (a: string) => number;
const func_5_variable: Func5 = (a) => {
    return 10;
}

// 呼び出しシグネチャ、戻り値がvoid
type Func6 = (a: string) => void;
const func_6_variable: Func6 = function func_6(a) {
    return;
}

呼び出しシグネチャにはFunc4の様にオブジェクトで定義する方式とFunc5の様にアロー式で定義する方式の2パターンあります。

見ての通り定義自体に難しい点はないと思います。

エラーが発生する戻り値

呼び出しシグネチャがどの様なパターンでエラーになるのかをみていきましょう。

まずは最も基本的なパターンです。

簡単な呼び出しシグネチャの例
// 呼び出しシグネチャの戻り値がnumberなのに戻り値も違う
type Func4Error1 = {
    (a: string): number
}
const func_4_variable_error_1: Func4Error1 = function func_4_error_1(a) {
}
const func_4_variable_error_2: Func4Error1 = function func_4_error_2(a) {
    return "Hello World"
}

呼び出しシグネチャで戻り値としてNumberが要求されていて、何も返さない関数やStringを返す関数などが渡されたパターンです。

これがエラーになるのは納得できるでしょう。では次の様なパターンではどうでしょうか?

戻り値voidの要求に戻り値numberの関数
type Func7 = (a: string) => void;
const func_7_variable: Func7 = (a) => {
    return 10;
}

戻り値がvoid呼び出しシグネチャに対して、戻り値がNumberの関数を渡しています。
一見すると、要求された関数と違う関数を渡しているためエラーが発生しそうですが…

実際にはこのコードは問題なくコンパイルが通ります

これは関数の変性(variance)が関係しています。

関数の変性

変数に値や関数を割り当てる場合には割り当て可能性(assignability)を考慮する必要があります。

TypeScriptの場合、割り当てる関数の型割り当てられる変数の型のサブタイプである必要があります。

関数A関数Bが以下の全てを満たす場合、関数A関数Bのサブタイプであると言えます。

  1. 関数Aのthis型が指定されていない、または関数Bのthis型が関数Aのthis型のサブタイプである。
  2. 関数Bの全ての引数の型が、関数Aの対応する引数の型のサブタイプである。
  3. 関数Aの戻り値の型が関数Bの戻り値の型のサブタイプである。

今回はの条件が問題になります。

まず簡単に以下の様なパターンを考えましょう。

戻り値についてサブタイプの例
// 返り値がstringまたはnumberを要求
type Func8 = (a: string) => string | number;
const func_8_variable: Func8 = (a): number => {
    return 10;
}

この場合、関数の戻り値numberは呼び出しシグネチャの戻り値string | numberのサブタイプであるため、問題ないですね。

では呼び出しシグネチャの戻り値指定がvoidではどうでしょうか?

実は呼び出しシグネチャの戻り値としてのvoidは、関数の戻り値を利用しないということだけを表す型で、全ての型を許容してしまうのです。

これはつまり、どんな戻り値の関数も指定できてしまうということを意味します。

問題が発生するパターン

呼び出しシグネチャの戻り値としてのvoidは、まれに非常にわかりにくいバグを発生させます。

最後に、どの様な場合にバグになるのか簡単な例を載せておきます。

/**
 * ただ与えられた関数を実行するだけのクラス
 */
class SampleExecuter{
    executeFunc (first: () => void, second: () => void) {
        first();
        second();
    }
}

/**
 * モンスターが現れるまでのセリフを管理する関数
 * @returns 
 */
const priorConversation = (): Promise<void> => {
    return new Promise<void>((resolve, reject) => {
        console.log("おっさん「この辺りはモンスターが出るぞ、気をつけろ」");
        setTimeout(() => {
            console.log("おっさん「そこにいるのは…モンスターだ!」");
            resolve();
        },3000);
    });
}

/**
 * モンスターとバトルする処理
 */
const startBattle = (): Promise<void> => {
    return new Promise<void>((resolve, reject) => {
        console.log("モンスターが現れた!!!");
        setTimeout(() => {
            console.log("おっさんの攻撃!!!モンスターを倒した!!!");
        },2000);
    });
}

// 実行
const sampleExecuter = new SampleExecuter();
sampleExecuter.executeFunc(priorConversation, startBattle);

SampleExecuterクラスのexecuteFunc()は与えられた関数をただ順番に実行するだけの関数です。executeFunc()の引数であるfirstsecond、はどちらも戻り値がvoid呼び出しシグネチャが設定してあります。

priorConversation()startBattle()は、どちらもコンソールに文字を出力するだけの関数です。この二つの関数をSampleExecuterにのexecuteFunc()に渡すのですが、ここで問題があります。

executeFunc()の引数であるfirstsecondはどちらも同期関数の想定だったのですが、実際に渡されたpriorConversation()Promiseを返す非同期関数なのです。

これをコンパイルして実行してみると以下の様な結果になります。

実行結果
おっさん「この辺りはモンスターが出るぞ、気をつけろ」
モンスターが現れた!!!
おっさんの攻撃!!!モンスターを倒した!!!
おっさん「そこにいるのは…モンスターだ!」

セリフの順番がめちゃくちゃになってしまいました。

最も簡単な解決策のひとつの例として、関数をasyncにし、呼び出しシグネチャの戻り値をvoid | Promise<void>にして、呼び出し時にawaitをつける方法があります。
修正すると以下の様になります。

/**
 * ただ与えられた関数を実行するだけのクラス
 */
class SampleExecuter{
    async executeFunc (first: () => void | Promise<void>, second: () => void | Promise<void>) {
        await first();
        await second();
    }
}

// [...]

この実装の場合firstsecondに渡されたのが同期関数でも非同期関数でも、ちゃんと順番通りに実装してくれます。

これはあくまで解決法の一つです。

単純にexecuteFunc()に非同期関数を渡すことを禁止したい場合などには、別の方法として、呼び出しシグネチャの戻り値の型をnullにするなどの対応も考えられるでしょう。また、非同期関数を渡すメソッドを別で作る等の対応も考えられます。

同じ様な状況に遭遇した時には、その都度最適な対処法を考えましょう。

このエントリーをはてなブックマークに追加


関連記事

記事へのコメント