はじめに
皆さんご存知の通り、TypeScript にはジェネリクスという機能が存在します。
…さて 問題です。
以下の2つの型は、見ての通りジェネリクスの宣言場所が異なりますが、何が異なるでしょうか。
type T1 = <T>(val: T) => T;
type T2<T> = (val: T) => T;
(▶ をクリックすると回答が表示されます)
え? タイトルに書いてあるって? かたい事言うなよ たかがクイズじゃねーか
正解は、タイトルにある通り、ジェネリクスのスコープが異なります。
具体的には、T1
のジェネリクスのスコープは、そのジェネリクスが宣言されたシグネチャ<T>(val: T) => T
に留まります。
一方、T2
のジェネリクスのスコープは、宣言されたシグネチャ全体type T2<T> = (val: T) => T;
になります。
以下、詳しく解説します。
環境
TypeScript: v4.1.3
コード
解説
2 種類の関数シグネチャの宣言方法
本題に入る前に、関数シグネチャの宣言方法について触れておきます。
ご存知の型は読み飛ばしてください。
関数シグネチャの宣言方法には、以下の 2 種類があります。これらが示す方は全く同一です。
普段使用するのはShortHand
かと思いますが、これはLongHand
の書き方を省略したものとなります。
type LongHand = {
(a: number): number;
};
type ShortHand = (a: number) => number;
参考: 関数の宣言
本記事では、スコープが分かりやすいLongHand
を主に使用します。
スコープの違い
実際に、先程省略記法で記述されたシグネチャを、元の形に戻してみます。
こうしてみると、スコープの違いが認識しやすくなると思います。
type T1 = <T>(val: T) => T;
type T2<T> = (val: T) => T;
// T1 === T3
type T3 = {
<T>(val: T): T;
};
// T2 === T4
type T4<T> = {
(val: T): T;
};
さらに、T3、T4 に全く同じ型(T3 はジェネリクスなし)をオーバーロードすると、違いが明白です。
T3 では、T
は 個々に宣言されたシグネチャでしか有効でないため、2 番目のシグネチャで使おうとするとエラーになります。
しかし、T4 のT
は T4 全体で有効なため、2 番目のシグネチャでも有効です。
type T3 = {
<T>(val: T): T;
(val: T): T; // Cannot find name 'T'.ts(2304)
};
type T4<T> = {
(val: T): T;
(val: T): T; // No Error
};
バインドのタイミングの違い
また、この 2 つの宣言方法はジェネリクスがバインドされるタイミングも異なります。
- T1, T3(個々に宣言された方)
- T1,T3 型の関数が実行されるタイミングでバインドされる
- T2, T4(全体に宣言された方)
- T2,T3 型を使用するタイミングでバインドされる
具体的に見てみましょう。
type T1 = <T>(val: T) => T;
type T2<T> = (val: T) => T;
// T1 === T3
type T3 = {
<T>(val: T): T;
};
// T2 === T4
type T4<T> = {
(val: T): T;
};
const fn1: T1 = (val) => val; // (parameter) val: T
const fn2: T2<number> = (val) => val; // (parameter) val: number
const fn3: T3 = (val) => val; // (parameter) val: T
const fn4: T4<number> = (val) => val; // (parameter) val: number
T2, T4 はシグネチャを使用するタイミングでジェネリクスをバインドする必要があります。
以下のようにバインドをしない場合、エラーになります。
// Generic type 'T2' requires 1 type argument(s).
const t2: T2 = (val) => val;
T2,T4 にジェネリクスをバインドしたことにより、ジェネリクスT
の型が number 型にバインドされます。
それにより、引数val
の型も変わっていることも分かります。
次に、実際に宣言した関数を実行してみます。
const fn1: T1 = (val) => val;
const fn2: T2<number> = (val) => val;
const fn3: T3 = (val) => val;
const fn4: T4<number> = (val) => val;
const num1 = fn1(1); // const v1: 1
const num2 = fn2(2); // const v2: number
const num3 = fn3(3 as number); // const v3: number
const num4 = fn4(4); // const num4: number
const str1 = fn1(""); // const str: ""
const str2 = fn2(""); // Argument of type 'string' is not assignable to parameter of type 'number'.
const bind = fn1<string>(""); // const bind: string
この通り、先にジェネリクスに number 型をバインドした T2, T4 は、返り値も同じ number 型となります。
一方、T1,T3 は関数が実行されるタイミングでジェネリクスがバインドされているのが分かります。
(引数の型から TypeScript が型を推論し、推論結果をジェネリクスT
にバインドしています。)
- num1: 引数の型が
1
なので、返り値の型も 1 - num3: 引数の型がアサーションにより number 型なので、返り値も number 型
- str1: 引数の型が
""
なので、返り値の型も""
- bind: 明示的に型を指定したため、返り値の型もそれに習って string 型となる
使い分け
実際に、よく使われる組み込み関数のArray.prototype.map()、Array.prototype.foreach()を例に、使い分けをしてみます。
それぞれの型は以下のようになっています。
interface Array<T> {
forEach(
callbackfn: (value: T, index: number, array: readonly T[]) => void,
thisArg?: any
): void;
map<U>(
callbackfn: (value: T, index: number, array: T[]) => U,
thisArg?: any
): U[];
}
実際に関数を実行してみることで、どう使い分けられているかイメージできるかと思います。
interface Array<T> {
forEach(
callbackfn: (value: T, index: number, array: readonly T[]) => void,
thisArg?: any
): void;
map<U>(
callbackfn: (value: T, index: number, array: T[]) => U,
thisArg?: any
): U[];
}
// この時点で、ジェネリクス`T`がnumber型にバインドされる
const array: Array<number> = [];
// foreach関数内では、シグネチャ全体に宣言されたジェネリクス`T`が使用可能
// ジェネリクス`T`がnumber型にバインとされているため、callbackfnの第一引数もnumber型
array.forEach((value) => {}); // (parameter) value: number
// map関数を実行したタイミングで、callbackfnの返り値( == string)をジェネリクス`U`にバインドする
// map関数の返り値は U[] === string[]
// もちろんこの`U`はmap関数外では使えない
const strArray = array.map((value) => String(value)); // const strArray: string[]
最後に
今回は foreach と map でしたが、他の array の関数もジェネリクスを理解する上でとても参考になるため、色々見てみるのもおすすめです!!