オーバーロードをアロー関数で書かないほうがいい理由[TypeScript]

  update(21/05/23): ・コード例が正しくなかったので修正しました。 ・ジェネリクスについて追記しました。

結論

TypeScript で関数をアロー関数で多重定義すると型推論がうまくいかないので、function キーワードのオーバーロードを使いましょう。

 

オーバーロード

TypeScript のオーバーロードは、引数と戻り値の個数や型が柔軟な関数に型をつける構文です。関数をオーバーロード(多重定義)することで、引数と戻り値 の組み合わせを複数定義できます。

declare function getWidget(n: number): Widget;
declare function getWidget(s: string): Widget[];

関数のオーバーロードをつかうことで、数値が与えられたら値の2乗を返し、数値の配列が与えられたら2乗の配列を返す関数を簡単に定義できます。

オーバーロードをアロー関数で書きたい

ところで、この構文のオーバーロードは関数宣言を使うことを強制しますが、関数宣言は var 同様巻き上げの恐れがあるため可能な限り避けたいです。解決策として、呼び出し可能オブジェクト (callable object) を使うことで、アロー関数でオーバーロードすることができます。

 

interface Add {
    (a:number):number;
    (a:number[]):number;
}

const add:Add =  (a:number|number[]):number => {
    if(Array.isArray(a)){
        return a.reduce((prev,curr) => prev + curr);
    }
    return a
}

上のコードのような素朴な例では問題ないですが、アロー関数のオーバーロードは関数宣言と異なる挙動を見せることがあります。 

 

戻り値が異なるオーバーロード型推論が意味をなさない

アロー関数で戻り値の型が2種類以上あるオーバーロードはエラーが出ます。

 

interface Add {
    (a:number, b:number):number;
    (a:string,b:string):string;
}

const add:Add =  (a:number|string, b:number|string) => {
//       ^ ここがError
    if (typeof a === 'string' && typeof b === 'string') {
      return a + b
      }

    return a * b;
}

Type '(a: number | string, b: number | string) => string | number' is not assignable to type 'Add'. Type 'string | number' is not assignable to type 'number'. Type 'string' is not assignable to type 'number'.(2322)

   これは TypeScript がオーバーロード関数の戻り値を number & string として認識しているせいで起こるエラーです。このエラーはたとえば戻り値が number と string の2種類であれば、型推論は number & string (= never) になり、どの戻り値も許容しないことを意味します。 この挙動は戻り値を number | string とアノテートしても変わりません。これをアロー関数で書く唯一の解決策はanyとアノテーションすることです。そこまでしてアロー関数にこだわる必要はないでしょう。  

関数宣言とアロー関数で挙動が違う理由

なぜアロー関数で書いたオーバーロードはこのような型推論をし、なぜ関数宣言と異なるのでしょうか?

githubのissueを読んだ限りでは次のような経緯でした。オーバーロードされた関数の戻り値は本来、取りうるすべての戻り値 (a, b, c...) の条件を満たさなければならないため、交差型 (a & b & c) になります。しかし一般的なオーバーロードの使用法は戻り値を引数に応じて変えることであり、そのためには戻り値が合併型 (union type: a | b ) である必要があります。 このissueを受けて関数宣言のオーバーロードは実用性のために合併型を許容するよう実装されましたが、その他の形で書かれたオーバーロードをもつ関数に拡張しても大丈夫か明確でないので適用しなかったようです。 github.com https://github.com/microsoft/TypeScript/pull/6075 https://github.com/microsoft/TypeScript/issues/37824

実装した人(一番上の記事とは別の人)のその後の反応をみるに、特段事情がなければアロー関数で実装するモチベーションはなさそうです。

結論

したがって、アロー関数のオーバーロードは本来の挙動ですが不便すぎるので、複数の戻り値を組み合わせる必要がある際には、関数宣言を使いましょう。  

(追記) ジェネリクス

ところでジェネリクスを知っているなら、ジェネリクスで書いてみたくなるかもしれません。 ジェネリクスは簡潔に書けることが多いですが、この場合はアサーションを使う必要があり、narrowingや推論もあまり期待通りに動いてくれません。

TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript

const isString = (X:unknown):X is string => typeof X === 'string';

function add<T extends string | number>(a: T, b:T) {
    if (typeof a ==='string' && typeof b === 'string') {
        return a  + b; 
    }
    // return a + b;     //Operator '+' cannot be applied to types 'T' and 'T'.(2365)
    // 上のコードだと戻り値の推論がanyになる
    return (a as number) + (b as number); //正しく推論されるにはアサーションが必要
}

const a = add('a', 'b'); // a はnumber | stringになり、stringと絞り込んでくれない
add(2, 3); // correct usage
add(1, 'a'); // should be error
add('a', 2); // should be error
const res = add('a', 'b') // correct usage

この解決方法はあるにはあるんですが、uglyなので参考リンクを紹介するにとどめておきます。 参考:Advanced TypeScript Exercises - Answer 3 - DEV Community

調べてみると、つい一月前に関連するPRがマージされていて、これにより引数が一つの場合には上記のコードでアサーションが必要だった部分が解消できるらしいです。 残念ながら引数が複数の今回のものはエラーが出たままのように見えます(あるいはv4.3.0-betaにも乗っていないPR?)。

Improve narrowing of generic types in control flow analysis by ahejlsberg · Pull Request #43183 · microsoft/TypeScript · GitHub

戻り値も同じ推論かとおもうのですが、型の絞り込みは効いてません。 TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript

いずれにせよ便利なことに違いはないです。いずれこのPRが複数の引数に一般化されるといいですね。