在上一章節中,我們介紹了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
需要透過轉型才可以存取其a
、b
屬性。例如:
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
必須不能是U
或U
的子型別。
Extract
Extract
定義如下:
type Extract<T, U> = T extends U ? T : never;
說明:T
必須要是U
或U
的子型別。
NonNullable
NonNullable
定義如下:
type NonNullable<T> = T extends null | undefined ? never : T;
說明:T
必須不可以為null
或undefined
。
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"
,所以型別為FKey
的key
變數可以避免使用到超出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));
以上程式,輸出結果如下:
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
關鍵字來創造型別的別名。我們也學會用typeof
和keyof
關鍵字來複製值或鍵值的型別,還學會函數的轉型了。除此之外,我們也了解了物件型別所使用的特殊屬性。
在下一個章節中要來補充幾個在ES6之後才加入的語法,提升我們撰寫程式碼的速度。