在先前的章節實作猜數字程式的時候,我們有用到Promise<string>這樣的型別。在這個型別中,由小於<和大於>符號括起來的部份稱為「泛型」(generic)。在「類型論」(type theory)中,「泛型」就是「參數多型」(parametric polymorphism),在定義型別的時候不去明確地指定具體的型別,而以參數的形式來傳入型別,如此一來擁有泛型的類別和函數只需要實作一次,就可以透過參數傳入不同型別的方式來將其應用在各種型別的資料下。



類別泛型

我們就實作出一個模擬堆疊(stack)空間的類別來練習泛型吧!程式碼如下:

class Stack {
    private data: any[] = [];

    push(element: any) {
        this.data.push(element);
    }

    pop() {
        return this.data.pop();
    }
}

以上實作了一個Stack類別,這個類別在被實體化之後會擁有一個私有的陣列,並提供pushpop方法來操作這個陣列。

我們可以撰寫出以下程式,來將任意型別的值透過push方法存進Stack類別的物件中,再用pop方法將這些值取出。

class Stack {
    private data: any[] = [];
 
    push(element: any) {
        this.data.push(element);
    }
 
    pop() {
        return this.data.pop();
    }
}
 
const stack = new Stack();
 
stack.push(1);
stack.push("2");
stack.push(true);
 
console.log(stack.pop());
console.log(stack.pop());
console.log(stack.pop());

以上程式的執行結果如下:

true
2
1

目前的Stack類別物件可以存入任意型別的值,如果我們想要限制值的型別該怎麼做呢?最直覺的方式就是利用前面的章節提到的物件轉型方式,將Stack物件的型別轉成其它型別。如下:

class Stack {
    private data: any[] = [];

    push(element: any) {
        this.data.push(element);
    }

    pop() {
        return this.data.pop();
    }
}

const numberStack: { push: (element: number) => void, pop: () => number } = new Stack();
const stringStack: { push: (element: string) => void, pop: () => string } = new Stack();
const booleanStack: { push: (element: boolean) => void, pop: () => boolean } = new Stack();

numberStack.push(1);
stringStack.push("2");
booleanStack.push(true);

console.log(booleanStack.pop());
console.log(stringStack.pop());
console.log(numberStack.pop());

這種作法雖然可行,但它不僅麻煩又很不安全。所以泛型的存在就很重要了,我們可以利用泛型,將以上程式改寫為:

class Stack<T> {
    private data: T[] = [];

    push(element: T) {
        this.data.push(element);
    }

    pop() {
        return this.data.pop();
    }
}

const numberStack = new Stack<number>();
const stringStack = new Stack<string>();
const booleanStack = new Stack<boolean>();

numberStack.push(1);
stringStack.push("2");
booleanStack.push(true);

console.log(booleanStack.pop());
console.log(stringStack.pop());
console.log(numberStack.pop());

Stack類別名稱後加上泛型參數T,就可以使用泛型參數T來代替類別內部用到的型別。使用new關鍵字來產生類別的實體物件時,若沒有明確定義泛型參數的型別(例如只寫new Stack(),而不寫new Stack<number>()),且也沒有在建構子的參數中被用到的泛型參數,編譯器會自動將那些泛型參數推論為unknown(稍候會介紹unknownany的差異)。換句話說,如果在建構子參數中有用到泛型參數,則編譯器會根據建構子被呼叫時實際傳入參數的值的型別來自動推論泛型參數的實際型別。

一個型別可以使用多個泛型參數,用逗號,隔開即可。例如:

class Stack2D<X, Y> {
    private data: [X, Y][] = [];
 
    push(element: [X, Y]) {
        this.data.push(element);
    }
 
    pop() {
        return this.data.pop();
    }
}
 
const numberStack = new Stack2D<number, number>();
const stringStack = new Stack2D<string, string>();
const booleanStack = new Stack2D<boolean, boolean>();
 
const numberStringStack = new Stack2D<number, string>();
const stringNumberStack = new Stack2D<string, number>();
 
numberStack.push([1, 2]);
stringStack.push(["1", "2"]);
booleanStack.push([true, false]);
 
numberStringStack.push([1, "2"]);
stringNumberStack.push(["1", 2]);
 
console.log(stringNumberStack.pop());
console.log(numberStringStack.pop());
console.log(booleanStack.pop());
console.log(stringStack.pop());
console.log(numberStack.pop());

unknownany型別的差異

unknownany型別有點相像,它們都可以用來儲存任意型別的值。

如下:

const a1: any = 1;
const a2: any = "2";
const a3: any = false;

const u1: unknown = 1;
const u2: unknown = "2";
const u3: unknown = false;

差異之處在於,any型別的值可以被直接轉型成其它任意型別,unknown型別的值則需要靠as關鍵字來強制轉型。

如下:

const a1: any = 1;
const a2: number = a1;

const u1: unknown = 1;
const u2: number = u1 as number;
const u3: number = u1; // compilation error

另外,unknown型別的值無法直接作為運算元、物件或是函數,必須要先轉型成其它可用型別才行。例如:

const a1: any = 1;
const a2: any = 2;
const a3 = a1 + a2;

const u1: unknown = 1;
const u2: unknown = 2;
const u3 = u1 as number + (u2 as number);
const u4 = u1 + u2; // compilation error

介面泛型

介面可以使用泛型。舉例來說:

interface Point<T> {
    x: T,
    y: T
}

class Vertex<T> implements Point<T> {
    x: T;

    y: T;

    constructor(x: T, y: T) {
        this.x = x;
        this.y = y;
    }
}

函數泛型

函數也可以使用泛型,例如:

function reverse(items: any[]): any[] {
    const output = [];
    for (let i = items.length - 1;i >= 0;i--) {
        output.push(items[i]);
    }
    return output;
}

const a = [1, 3, 5, 7];
const a_rev = reverse(a);

a_rev[0] = "1";

以上的reverse函數,可以傳入任意型別的陣列,並將其反向。

我們可以發現,由於reverse函數回傳的值的型別為any[],而把原先的型別約束破壞掉了。替reverse函數加上泛型,就可以輕易地解決這個問題。程式如下:

function reverse<T>(items: T[]): T[] {
    const output = [];
    for (let i = items.length - 1;i >= 0;i--) {
        output.push(items[i]);
    }
    return output;
}

const a = [1, 3, 5, 7];
const a_rev = reverse(a);

a_rev[0] = "1"; // compilation Error

以上程式第10行,可以不必將呼叫reverse的表達式寫成reverse<number>(a),因為TypeScript的編譯器會自動推論泛型參數T所代表的實際型別。(由於變數a的型別為number[],因此泛型參數T會被自動推論為number。)

匿名函數也可以使用泛型,程式如下:

const reverse = function <T> (items: T[]): T[] {
    const output = [];
    for (let i = items.length - 1;i >= 0;i--) {
        output.push(items[i]);
    }
    return output;
};

const a = [1, 3, 5, 7];
const a_rev = reverse(a);

a_rev[0] = "1"; // compilation Error

Lambda語法的匿名函數也可以使用泛型,程式如下:

const reverse = <T>(items: T[]): T[] => {
    const output = [];
    for (let i = items.length - 1;i >= 0;i--) {
        output.push(items[i]);
    }
    return output;
};

const a = [1, 3, 5, 7];
const a_rev = reverse(a);

a_rev[0] = "1"; // compilation Error

泛型參數的預設值

前面有提到如果類別的泛型參數沒有明確定義出來,且也未在建構子的參數中被使用到的話,就會被推論為unknown。其實這點在使用有泛型的函數時也會發生,只不過比較少見。例如:

function f<T>(): T[] {
    return [];
}
 
const o = f();
 
o.push(1);
o.push("2");

以上的泛型參數T並未被用在f函數的參數中,所以會被編譯器自動推論為unknown

如果要讓編譯器可以將這類的泛型參數自動推論為其它我們指定的型別,而不是any的話,可以在泛型參數名稱後面加上=字元,再接上型別。

例如:

function f<T = number>(): T[] {
    return [];
}

const o = f();

o.push(1);
o.push("2"); // compilation Error

泛型參數的繼承限定

在定義型別參數的時候,可以使用extends關鍵字來限定這個型別參數實際的型別一定要是哪一個型別,或是其子型別。

例如:

interface PointX {
    x: number
}
 
interface PointY {
    y: number
}
 
interface PointZ {
    z: number
}
 
interface Point2D extends PointX, PointY {
 
}
 
interface Point3D extends Point2D, PointZ {
 
}
 
class Vertex implements Point3D {
    x: number;

    y: number;

    z: number;
 
    constructor(x: number, y: number, z: number) {
        this.x = x;
        this.y = y;
        this.z = z;
    }
}
 
function getX<T extends PointX>(vertex: T): number {
    return vertex.x;
}
 
function getY<T extends PointY>(vertex: T): number {
    return vertex.y;
}
 
function getZ<T extends PointZ>(vertex: T): number {
    return vertex.z;
}
 
const v = new Vertex(1, 2, 3);
 
console.log(getX(v));
console.log(getY(v));
console.log(getZ(v));

以上程式的輸出結果如下:

1
2
3

extends關鍵字後面可以加上keyof關鍵字,再加上一個物件型別,就表示要將泛型參數限定為這個物件型別的屬性鍵值的型別或是該型別的子型別。例如:

interface PointX {
    x: number
}
 
interface PointY {
    y: number
}
 
interface PointZ {
    z: number
}
 
interface Point2D extends PointX, PointY {
 
}
 
interface Point3D extends Point2D, PointZ {
 
}
 
class Vertex implements Point3D {
    x: number;

    y: number;

    z: number;
 
    constructor(x: number, y: number, z: number) {
        this.x = x;
        this.y = y;
        this.z = z;
    }
}
 
function getX<T extends PointX>(vertex: T): number {
    return vertex.x;
}
 
function getY<T extends PointY>(vertex: T): number {
    return vertex.y;
}
 
function getZ<T extends PointZ>(vertex: T): number {
    return vertex.z;
}
 
function get<T extends Vertex, K extends keyof Vertex>(vertex: T, key: K): number {
    return vertex[key];
}

事實上,泛型所用的extendskeyof關鍵字是可以直接被用在型別中的,這個我們會在之後的章節詳細介紹。

總結

在這個章節中我們學會了TypeScript提供的泛型功能,它可以讓我們的程式看起來寫簡潔,用起來也更安全。下一個章節,我們要來更深入地探討TypeScript的型別。

下一章:進階的型別用法