上一章節中,我們介紹了TypeScript的泛型,在這個章節我們會繼續介紹更多型別的應用。



型別的聯集(union)運算

我們在先前的章節中,有在型別中使用|字元來連接兩個不同的型別。例如A | B,表示這個型別的值可以為A型別或是B型別。

然而事實上,|字元並不是那麼單純的東西。先看看以下這個例子:

const o1: { a: number } = { a: 1, b: "2" };
const o2: { b: string } = { a: 1, b: "2" };

以上的兩個程式敘述都會編譯失敗,因為{a: 1, b: "2"}的型別為{a: number, b: string},所以若要讓以上程式能夠通過編譯,就必須要用如下的方式來轉型:

const o = { a: 1, b: "2" };
const o1: { a: number } = o;

const o2: { b: string } = { a: 1, b: "2" } as { b: string };

以上程式中,o2變數那行使用了as關鍵字來轉型,這看起來沒什麼問題。但是o1變數那邊只是把原來的{a: 1, b: "2"}物件事先存到o變數中,為什麼拆開來寫就可以編譯了?這應該是TypeScript怕開發者在首次明確定義大括號物件的型別時,不小心沒寫到物件中有用到的屬性才做的限制吧!

順帶一提,陣列也存在著類似的限制,但陣列必須要使用as來強制轉型才能夠忽略不想使用的元素。以下例子看看就好,很少看到有人這樣用:

const a = [1, "2", false];
const a1: [number, string] = a; // compilation Error

const a2: [number, string] = a as [number, string];

回到物件上,基於這個限制,如果我們將以下程式的o1變數和o2變數的型別用|組合在一起,

const o1: { a: number } = { a: 1, b: "2" };
const o2: { b: string } = { a: 1, b: "2" };

使其變為:

const o: { a: number } | { b: string } = { a: 1, b: "2" };

理論上以上這個程式應該也是不能成功編譯才對。但實際上,它是可以通過編譯的!

而以上的變數o需要透過轉型才可以存取其ab屬性。例如:

const o: { a: number } | { b: string } = { a: 1, b: "2" };

const oa = o as { a: number };

console.log(oa.a);

const ob = o as { b: string };

console.log(ob.b);

所以我們可以看出在型別中使用|字元,並不只是單純用來列出「值可能的型別」,它還有一點聯集(如{ a: number } | { b: string }像是被合併為{ a: number, b?: string } | { a?: number, b: string }),又或者說「列出來的型別中,要有至少一個是值可以直接轉型過去的型別」的概念存在。

型別的交集(intersection)運算

型別中除了能使用|字元來連接兩個不同的型別外,還可以用&字元來連接兩個不同的型別。&字元有一點交集(如{ a: number } & { b: string }像是被合併為{ a: number, b: string }),又或者說「列出來的型別,全部都必須是值可以直接轉型過去的型別」的概念存在。

例如:

const o: { a: number } & { b: string } = { a: 1, b: "2" };

console.log(o.a);
console.log(o.b);

型別的群組

利用小括號(),可以將括號內的型別當作一個群組。

例如,若我們想要定義一個數值或是字串的陣列型別,如果寫成以下這樣的話就會有問題:

const a: string | number[] = ["I am a string."];

正確的寫法應為:

const a: string[] | number[] = ["I am a string."];

或是利用小括號(),將string | number括起來當作群組。

const a: (string | number)[] = ["I am a string."];

型別的別名

使用type關鍵字可以替一個型別定義出一個新別名,方便重複使用。

例如:

type A = { a: number };
type B = { b: string };

type AB = A | B;

const o: AB = { a: 1, b: "2" };

const oa = o as A;

console.log(oa.a);

const ob = o as B;

console.log(ob.b);

別名也可以使用泛型。例如:

type MyType<T> = T | undefined;

型別的條件運算

型別中可以配合extends關鍵字來使用三元條件運算子,判斷一個型別是否屬於另一個型別的子型別(或者兩個型別相等)。

例如:

type A<T, K> = T extends K ? number : string;

以上這個A型別,若T泛型參數實際的型別不是K泛型參數實際的型別或是其子型別,則這個A型別就是string;反之就是number

用來做型別運算的TypeScript內建型別

除了剛才介紹的&|、小括號()extends關鍵字與三元條件運算子可以做型別運算之外,TypeScript還內建了以下幾種常用的型別。

Exclude

Exclude定義如下:

type Exclude<T, U> = T extends U ? never : T;

說明:T必須不能是UU的子型別。

Extract

Extract定義如下:

type Extract<T, U> = T extends U ? T : never;

說明:T必須要是UU的子型別。

NonNullable

NonNullable定義如下:

type NonNullable<T> = T extends null | undefined ? never : T;

說明:T必須不可以為nullundefined

ReturnType

ReturnType定義如下:

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

注意這邊的函數回傳值型別用到了infer關鍵字,表示要讓TypeScript根據函數實際用return關鍵字回傳出來的值(如果沒有用的話就是void),來推論R這個泛型參數實際代表的型別。infer關鍵字一定要用在extends關鍵字所組成的三元條件運算子的第一個運算元中

說明:T必須要是一個函數,且ReturnType<T>為這個函數的回傳值型別。

InstanceType

InstanceType定義如下:

type InstanceType<T extends new (...args: any) => any> = T extends new (...args: any) => infer R ? R : any;

說明:T必須要是一個函數或是類別,且InstanceType<T>為這個函數或是類別被使用new關鍵字後所建立出來的物件實體的型別。

複製值的型別

前面的章節有介紹過使用typeof關鍵字來取得某表達式的回傳值的型別。在型別中,我們也可以使用typeof關鍵字來取得值的型別。換句話說,可以「複製」這個值的型別,用在其它地方。

例如:

let foo = 123;
let bar: typeof foo;

以上程式,foo變數的型別為number,而bar變數的型別被明確定義為typeof foo,表示要使用foo變數的型別,也就是number

typeof關鍵字用在型別上時,其後只能夠接上有在TypeScript程式中被定義的名稱或是路徑,無法直接使用定數或是表達式。

再舉一個例子:

class Foo {
    foo = 0;
}

declare let _foo: Foo;

let bar: typeof _foo.foo;

以上程式,沒有實際產生Foo類別的物件,而是使用declare關鍵字假宣告了一個型別為Foo_foo變數,並在程式第7行利用typeof關鍵字來取得Foo類別的foo物件屬性的型別,也就是number

有個蠻容易會被雷到的地方要提一下,前面的章節有介紹過定數型別,常數的值若是基本資料型別的值,則常數的型別會被推論為這個值的定數。所以當typeof關鍵字後面接的是一個常數時,就要注意一下這樣的程式邏輯是不是真的是我們要的。

舉例來說:

const foo = "Hello World";

let bar: typeof foo;

bar = "Hello World";
bar = "something else"; // compilation Error

以上程式,第六行會編譯失敗,因為bar的型別為"Hello World"

複製鍵值的型別

除了值的型別能夠被複製之外,物件的鍵值的型別也是可以複製的。

若要複製物件的鍵值,只要在typeof關鍵字前再加上keyof關鍵字就好。

舉例來說:

class F {
    readonly name: string;

    readonly age: number;

    readonly studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

const o = new F("David", 18);

type FKey = keyof typeof o;

let key: FKey;

key = "name";
key = "age";
key = "studentNumber";
key = "toString";

以上程式,FKey這個型別其實就是"name" | "age" | "studentNumber" | "toString",所以型別為FKeykey變數可以避免使用到超出F類別定義的屬性名稱。

可惜的是,如果物件是陣列的話,我們沒有辦法利用這個方式來限制該陣列能夠存取的索引值範圍,因為陣列的索引值範圍就是整個number型別的值。舉例來說:

const o = [1, 3, 5, 7];

let key: keyof typeof o;

key = "length";
key = 1;
key = 2;
key = 3;
key = 4;

雖然我們知道以上的o陣列並不存在索引值4,但程式第9行還是可以通過編譯。

指定Node.js的require函數回傳的型別

由於Node.js內建的require函數是在程式執行階段動態載入指定的模組,因此儘管有Node.js的.d.ts定義檔案,TypeScript在編譯階段依然無法得知require函數確切的回傳值型別,只能先將其以any來看待。

以前面的章節寫過的猜數字程式來說明,程式碼如下:

const isBrowser: boolean = typeof window === "object";
	 
function showMessage(text: string): void {
    if (isBrowser) {
        alert(text);
    } else {
        console.log(text);
    }
}
 
async function showMessageAndInputText(text: string): Promise<string | null> {
    if (isBrowser) {
        return prompt(text);
    } else {
        console.log(text);
 
        const readline = require("node:readline");
 
        const rl = readline.createInterface({ input: process.stdin });
 
        const lineResolver: Promise<string> = new Promise<string>(resolve => {
            rl.on("line", (line: string) => resolve(line));
        });
 
        const line: string = await lineResolver;
 
        rl.close();
 
        return line;
    }
}
 
async function main(): Promise<void> {
    showMessage("Guess the number!");
 
    const secretNumber: number = Math.floor(Math.random() * 100 + 1);
 
    showMessage(`The secret number is: ${secretNumber}`);
 
    while (true) {
        const line: string | null = await showMessageAndInputText("Please input your guess.");
 
        if (line === null) {
            continue;
        }
 
        const guess: number = parseInt(line);
 
        if (isNaN(guess)) {
            showMessage("Please type a number!");
        } else {
            showMessage(`You guessed: ${guess}`);
 
            if (guess < secretNumber) {
                showMessage("Too small!");
            } else if (guess > secretNumber) {
                showMessage("Too big!");
            } else {
                showMessage("You win!");
                break;
            }
        }
    }
}
 
main();

以上程式第17行,使用require關鍵字來載入readline模組,此時的readline變數的型別為any,這很顯然地會讓我們無法好好地應用TypeScript的型別檢查機制。

為了讓TypeScript能夠認識模組的型別,我們可以在程式最外層,使用import關鍵字來宣告出一個自定義的模組名稱,來表示一個模組。例如:

import readline = require("node:readline");

const isBrowser: boolean = typeof window === "object";

function showMessage(text: string): void {
    if (isBrowser) {
        alert(text);
    } else {
        console.log(text);
    }
}

async function showMessageAndInputText(text: string): Promise<string | null> {
    if (isBrowser) {
        return prompt(text);
    } else {
        console.log(text);

        // const readline = require('node:readline');

        const rl = readline.createInterface({ input: process.stdin });

        const lineResolver: Promise<string> = new Promise<string>(resolve => {
            rl.on("line", (line) => resolve(line));
        });

        const line: string = await lineResolver;

        rl.close();

        return line;
    }
}

async function main(): Promise<void> {
    showMessage("Guess the number!");

    const secretNumber: number = Math.floor(Math.random() * 100 + 1);

    showMessage(`The secret number is: ${secretNumber}`);

    while (true) {
        const line: string | null = await showMessageAndInputText("Please input your guess.");

        if (line === null) {
            continue;
        }

        const guess: number = parseInt(line);

        if (isNaN(guess)) {
            showMessage("Please type a number!");
        } else {
            showMessage(`You guessed: ${guess}`);

            if (guess < secretNumber) {
                showMessage("Too small!");
            } else if (guess > secretNumber) {
                showMessage("Too big!");
            } else {
                showMessage("You win!");
                break;
            }
        }
    }
}

main();

透過import關鍵字和require函數的搭配使用,此時的我們自定義的模組名稱readline就有了正確的型別定義。但是別忘了,我們的猜數字程式必須也能要能運行在網頁瀏覽器上,以上這樣的寫法編譯出來的JavaScript程式雖然可在Node.js環境中正常執行,卻沒有辦法在網頁瀏覽器上正常執行。

import關鍵字寫進showMessageAndInputText函數中是鐵定行不通的,因為import關鍵字必須要寫在程式最外層。

那該怎麼做才好?魚和熊掌不能兼得嗎?

由於我們的目的只是要讓TypeScript能夠去抓取傳入require函數的模組名稱(或路徑)的.d.ts定義檔,所以大可不去使用import關鍵字宣告出來的名稱,就讓它因為沒有被使用而直接被TypeScript在編譯階段省略掉。把它宣告出來的用途,只是為了要利用剛才介紹的typeof關鍵字來做型別的複製,讓猜數字程式內的readline變數能夠有更精確的型別定義。

修改之後如下:

import _readline = require("node:readline");

const isBrowser: boolean = typeof window === "object";

function showMessage(text: string): void {
    if (isBrowser) {
        alert(text);
    } else {
        console.log(text);
    }
}

async function showMessageAndInputText(text: string): Promise<string | null> {
    if (isBrowser) {
        return prompt(text);
    } else {
        console.log(text);

        const readline: typeof _readline = require("node:readline");

        const rl = readline.createInterface({ input: process.stdin });

        const lineResolver: Promise<string> = new Promise<string>(resolve => {
            rl.on("line", (line) => resolve(line));
        });

        const line: string = await lineResolver;

        rl.close();

        return line;
    }
}

async function main(): Promise<void> {
    showMessage("Guess the number!");

    const secretNumber: number = Math.floor(Math.random() * 100 + 1);

    showMessage(`The secret number is: ${secretNumber}`);

    while (true) {
        const line: string | null = await showMessageAndInputText("Please input your guess.");

        if (line === null) {
            continue;
        }

        const guess: number = parseInt(line);

        if (isNaN(guess)) {
            showMessage("Please type a number!");
        } else {
            showMessage(`You guessed: ${guess}`);

            if (guess < secretNumber) {
                showMessage("Too small!");
            } else if (guess > secretNumber) {
                showMessage("Too big!");
            } else {
                showMessage("You win!");
                break;
            }
        }
    }
}

main();

如此一來,以上的TypeScript程式所編譯出來的JavaScript程式就可以同時被用在Node.js和網頁瀏覽器中。

import關鍵字還有很多功能,會在之後的章節詳細介紹。另外,注意這邊的import關鍵字用法會導致TypeScript程式無法在編譯目標設為ES6或以上時成功編譯,我們在之後的章節介紹到模組時會順便解決這個問題。

函數的轉型

前面的章節介紹的物件轉型,在嚴格模式下無法套用在函數的參數型別上。

例如以下程式:

const o: { f1: number, f2: string } = {
    f1: 1,
    f2: "2",
};

const oo: { f1: number } = o;

const f: (o: { f1: number, f2: string }) => void = (o: { f1: number, f2: string }) => {
};

const ff: (o: { f1: number }) => void = f;

const fff: (o: { f1: number }) => void = (o: { f1: number, f2: string }) => {
};

在嚴格模式下,變數ff和變數fff的宣告敘述都會編譯錯誤。要在非嚴格模式下才會編譯成功。

但在嚴格模式下,函數回傳的物件型別是可以轉型的。

const o: { f1: number, f2: string } = {
    f1: 1,
    f2: "2",
};

const oo: { f1: number } = o;

const f: () => { f1: number, f2: string } = () => ({
    f1: 1,
    f2: "2",
});

const ff: () => { f1: number } = f;

const fff: () => { f1: number } = () => ({
    f1: 1,
    f2: "2",
});

甚至不知道為什麼,任何函數的回傳值型別都可以轉型為void

const f1: () => void = () => undefined;
const f2: () => void = () => 1;
const f3: () => void = () => "2";
const f4: () => void = () => false;
const f5: () => void = () => ({ f1: 1, f2: "2" });

此外,就算在嚴格模式下,函數的參數還是可以在轉型時被增加。

const o: { f1: number, f2: string } = {
    f1: 1,
    f2: "2",
};

const oo: { f1: number } = o;

const f: (o: { f1: number, f2: string }) => void = (o: { f1: number, f2: string }) => {
};

const ff: (o: { f1: number, f2: string, f3: boolean }) => void = f;

const fff: (o: { f1: number, f2: string, f3: boolean }) => void = (o: { f1: number, f2: string }) => {
};

特殊的物件屬性

TypeScript的物件型別預設了幾個擁有特殊功能的「屬性」。

可被呼叫的物件型別

假如f是一個函數,則f()敘述可以呼叫它。這我們都知道,但還記得先前的章節有提過「函數也是物件」嗎?也就是說,函數和物件一樣也可以有一些屬性。那麼問題就來了,如果函數有屬性,那它的型別應該要怎麼表示呢?

其實很簡單,只要直接將函數的型別,寫進物件型別的大括號{}內,把=>改為:,使參數型別的部份作為一個屬性,這個物件就會是一個「可被呼叫」的物件。換句話說,假如o是一個物件,則o()敘述可以呼叫它。

舉例來說:

const f: (n: number) => number = function (n: number) {
    return n + 1;
};

若要替以上這個f函數增加屬性,可以將其的型別先改為:

const f: { (n: number): number } = function (n: number) {
    return n + 1;
};

之後就可以添加任何我們想要的屬性。例如:

const f: { (n: number): number, f1?: number, f2?: string } = function (n: number) {
    return n + 1;
};

f.f1 = 1;
f.f2 = "2";

console.log(f.f1);
console.log(f.f2);
console.log(f(100));

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

1
2
101

可被「new」的物件型別

假如F是一個函數,則new F()敘述可以建立出這個函數的物件實體。然而,還記得在前面的章節有提到,在TypeScript的嚴格模式下對於函數我們必須要用as關鍵字將其先強制轉型為any,才能夠使用new關鍵字嗎?所以new F()敘述在嚴格模式下應該要改為new (F as any)()敘述才能編譯。

例如:

function F(this: { t: number }, n: number) {
    this.number = n;
}

const a = new (F as any)();

console.log(a.t);

雖然以上程式在嚴格模式下可以編譯,但很明顯地,這個函數在使用建構子時應該要將一個數值代入n參數,而這邊的程式第5行顯然並沒有這樣做。

我們其實可以自行明確地定義函數的建構子型別,以及建構出來的物件的型別來解決這個問題。建構子的型別定義方式有點像是上面介紹的「可被呼叫的物件型別」的定義方式,只要直接將建構子的型別,寫進物件型別的大括號{}內,把=>改為:,再在參數型別的部份前加上new,這個函數(物件)就會是一個「可被new」的函數(物件)。不過,這個方式依然需要先靠as any來轉型。

例如:

const F: { new(n: number): { t: number } } = function (this: { t: number }, n: number) {
    this.t = n;
} as any;

const a = new F(1);

console.log(a.t);

除非是想快速地將JavaScript程式轉為TypeScript程式,不然一般我們還是會選擇使用類別來建立物件,而不是函數。

有多載的物件方法

當物件有了多載方法時,它的型別要怎麼表示呢?

先看看以下的這個物件:

const o: { name: string, age: number, studentNumber: string, toString: () => string } = {
    name: "David",
    age: 18,
    studentNumber: "B12345678",
    toString: function () {
        return `${this.age}: ${this.name}`;
    },
};

以上的o變數的型別,其實可以改成以下這樣:

const o: { name: string, age: number, studentNumber: string, toString(): string } = {
    name: "David",
    age: 18,
    studentNumber: "B12345678",
    toString: function () {
        return `${this.age}: ${this.name}`;
    },
};

我們將toString屬性的寫法直接變成函數型別,但把=>改為:了!透過這樣的語法,我們可以在型別中添加更多的toString方法,並將程式修改如下:

const o: { name: string, age: number, studentNumber: string, toString(): string, toString(includeStudentNumber: boolean): string } = {
    name: "David",
    age: 18,
    studentNumber: "B12345678",
    toString: function (includeStudentNumber?: boolean) {
        if (includeStudentNumber) {
            return `${this.name}: ${this.age}, ${this.studentNumber}`;
        }
        return `${this.name}: ${this.age}`;
    },
};

如此一來,o這個物件的toString就變成多載方法啦!(雖然實際上它仍然只有一個函數……)

總結

在這個章節中,我們學會了TypeScript型別的運算,也利用了type關鍵字來創造型別的別名。我們也學會用typeofkeyof關鍵字來複製值或鍵值的型別,還學會函數的轉型了。除此之外,我們也了解了物件型別所使用的特殊屬性。

在下一個章節中要來補充幾個在ES6之後才加入的語法,提升我們撰寫程式碼的速度。

下一章:陣列和物件的解構(Destructuring)