上層型別
在 [TypeScript] 物件 文章當中,提到了底限型別(bottom type)觀念,用來描述一種不可能的資料且無法存取的型別(例如 never 型別)。
然而,上層型別(top type)或泛型型別,是一種可以表示系統中任何可能值的型別。
any 型別
any 型別可以當上層型別,也就是任何型別的資料都可以提供給 any 型別,例:
let anyValue: any;
anyValue = "abc"; // 允許
anyValue = 123; // 允許
console.log(anyValue); // 允許
any 的問題在於明確告訴 TypeScript ,不要對該變數的可指派性或成員執行型別檢查。如果想繞過 TypeScript 的型別檢查,那麼缺乏安全性是可以考慮的方式之一。
例如以下的 name.toUpperCase() 肯定會當機,但是因為 name 被宣告為 any,所以 TypeScript 不會回報型別錯誤:
function greetComedian(name: any) {
// 沒有型別錯誤
console.log("aaa " + name.toUpperCase());
}
// 以下是在執行時期才會報錯:name.toUpperCase 並不是一個函式
greetComedian({name: "bbb"});
如果想要表明一個變數可以是任何東西的話,那麼使用 unknown 型別會更安全。
unknown 型別
unknown 型別是 TypeScript 中真正的上層型別。unknown 與 any 很類似,因為所有物件都可以傳遞給型別 unknown。與 unknown 的主要區別,在於 TypeScript 對 unknown 型別的資料有更多的限制:
- TypeScript 不允許直接存取 unknown 型別變數的屬性,也就是一個變數的型別是 unknown 的話,底下就沒有屬性。
- unknown 不能指派給不是上層型別(any 或 unknown) 的型別。
例:嘗試存取 unknown 型別變數的屬性,將導致 TypeScript 回報型別錯誤:
function greetComedian(name: unknown) {
// 以下報錯:name 的型別為 unknown
console.log("aaa " + name.toUpperCase());
}
透過窄化變數的型別,是 TypeScript 允許 程式碼存取型別為 unknown 的成員的唯一方式,例如使用 instanceof 或 typeof 或型別斷言。例:
function greetComedianSafety(name: unknown) {
if(typeof name === "string"){
console.log("aaa " + name.toUpperCase()); // 允許
} else {
console.log("bbb");
}
}
以上的兩個限制,使得 unknown 成為比 any 型別還要更安全的型別,如果可能,應該更常使用 unknown。
型別敘述
以下的 isNumberOrString 函式接受一個變數,並回傳一個 boolean 資料,表示該變數是 number 還是 string。在 TypeScript 中,它所知道的只是 isNumberOrString 回傳一個 boolean 資料,並沒有窄化參數的型別:
function isNumberOrString(value: unknown) {
return ["number", "string"].includes(typeof value);
}
function logValueIfExists(value: number | string | null | undefined) {
if(isNumberOrString(value)){
// 報錯:value 可能為 null 或 undefined
value.toString();
} else {
console.log("ddd");
}
}
對於回傳 boolean 的函式,TypeScript 有一種特殊的語法,用於表示參數是否為特定型別。這稱為型別敘述(type predicate),也稱為「使用者定義的型別防護」:當開發人員正在建立自己的型別防護,類似於 instanceof 或 typeof。型別敘述,通常用於判斷傳入的參數是否是更具體的參數型別。
回傳型別的敘述,可以用參數名稱加上 is 關鍵字和某些型別來宣告,例:
function typePredicate(input: WideType): input is NarrowType;
所以前面的例子,可以改成以下,就不會報錯:
function isNumberOrString(value: unknown): value is number | string {
return ["number", "string"].includes(typeof value);
}
function logValueIfExists(value: number | string | null | undefined) {
if(isNumberOrString(value)){
// 正確:value 的型別為:number | string
value.toString();
} else {
// value 的型別為:null | undefined
console.log(value);
}
}
再看另一個例子,型別敘述通常用於檢查已知是一個介面實體的物件,是否能夠表示成為更具體的介面實體。
以下的 StandupComedian 介面包含關於 Comedian 的額外資訊。isStandupComedian 型別保護可用來檢查一般 Comedian,是否是明確的 StandupComedian:
interface Comedian {
funny: boolean;
}
interface StandupComedian extends Comedian {
routine: string;
}
function isStandupComedian(value: Comedian): value is StandupComedian {
return "routine" in value;
}
function workWithComedian(value: Comedian) {
if(isStandupComedian(value)){
// value 的型別:StandupComedian
console.log(value.routine); // 允許
}
// value 的型別:Comedian
console.log(value.routine);
// 以上這行會報錯:型別 Comedian 沒有屬性 routine
}
型別運算符號
有時可能需要建立一個將兩個型別結合起來的新型別,對現有型別的屬性執行某些轉換。
keyof
例:
interface Ratings {
audience: number;
critic: number;
}
function getRating(ratings: Ratings, key: string): number {
return ratings[key];
// 以上這行會報錯:因為 string 型別的運算式無法用於索引型別 Ratings
}
const ratings: Ratings = {audience: 66, critic: 84};
getRating(ratings, "audience"); // 允許
getRating(ratings, "not"); // 允許,但不應該
將 getRating 函式,換成用以下方式(字面聯集)就可以:
interface Ratings {
audience: number;
critic: number;
}
function getRating(ratings: Ratings, key: "audience" | "critic"): number {
return ratings[key]; // 這行沒有報錯了
}
const ratings: Ratings = {audience: 66, critic: 84};
getRating(ratings, "audience"); // 允許
getRating(ratings, "not"); // 報錯:型別 "not" 的引數不可指派給型別 "audience" | "critic" 的參數
但上述的字面聯集方式有點麻煩,可改成使用 keyof 運算符號,將接受現有型別,所以改成以下:
interface Ratings {
audience: number;
critic: number;
}
function getCountKeyof(ratings: Ratings, key: keyof Ratings): number {
return ratings[key];
}
const ratings: Ratings = {audience: 66, critic: 84};
getCountKeyof(ratings, "audience");
getCountKeyof(ratings, "not"); // 報錯:型別 "not" 的引數不可指派給型別 keyof Ratings 的參數
keyof 是很棒的功能,可以根據現有型別的鍵值來建立聯集型別。
typeof
TypeScript 提供另一個型別運算符號是 typeof,它回傳變數的型別。例:
const original = {
medium: "movie",
title: "abc"
};
// adaptation 變數跟 original 變數一樣,具有相同的型別
let adaptation: typeof original;
if(Math.random() > 0.5) {
// 允許
adaptation = { ...original, medium: "play" };
} else {
// 以下報錯:型別 number 不可指派給型別 string
adaptation = { ...original, medium: 2 };
}
留意:在 TypeScript 中的 typeof 運算符號,看起來跟執行時期(runtime)的 typeof 相似,都回傳一個變數型別的描述,但兩者是不同的,只是他們恰巧使用了同一個字。記得:JavaScript 的 typeof 運算符號是一個執行時期的運算符號,它回傳型別的字串名稱;然而,在 TypeScript 當中,因為是型別運算符號,所以只能在型別系統中使用,不會出現在編譯的程式碼中。
keyof typeof
可以兩個一起使用,typeof 是回傳變數的型別,keyof 是針對型別上允許的鍵值。
例:
const ratings = {
imdb: 8.4,
metacritic: 82
};
function logRating(key: keyof typeof ratings) {
console.log(ratings[key]);
}
logRating("imdb"); // 允許
// 以下報錯:型別 "invalid" 的引數,不可指派給型別 "imdb" | "metacritic" 的參數
logRating("invalid");
型別斷言
當程式碼是「強型別」時,對 TypeScript 是最好的:也就是程式碼中所有變數,都具有明確已知的型別。但有時,仍無法 100% 準確。
例如,JSON.parse() 試圖回傳 any 上層型別。沒有變法妥善安全通知型別系統,讓 JSON.parse() 接收特定字串變數後,回傳某個特定型別。
所以,TypeScript 提供一種語法:「型別斷言(type assertion)」,也稱為型別轉換(type cast)。也就是將 as 關鍵字放在一個型別之前,TypeScript 將遵循設定的斷言,將變數視為該型別。
以下的程式當中,將型別從 any 切換到其中一個:
const rawData = '["a", "b"]';
JSON.parse(rawData); // 型別:any
JSON.parse(rawData) as string[]; // 型別轉換到 string[]
JSON.parse(rawData) as [string, string]; // 型別轉換到 [string, string]
JSON.parse(rawData) as ["a", "b"]; // 型別轉換到 ["a", "b"]
型別斷言僅存在於 TypeScript 型別系統中,在編譯成 JavaScript 時,都會被刪除,所以上述程式,編譯後會變如下:
const rawData = '["a", "b"]';
JSON.parse(rawData);
JSON.parse(rawData);
JSON.parse(rawData);
JSON.parse(rawData);
註:儘量避免使用型別斷言,理想的狀況下,是程式都已完全型別化。
捕捉型別斷言的錯誤
錯誤處理是型別斷言可能會用到的地方。因為通常不會知道在 catch 區塊當中,捕捉到的錯誤是什麼型別,因為可能會意外拋出不同的物件(可能是 Error 類別的實體),或其它字串之類的資料。
例:
try {
// 這裡的程式,可能會拋出例外錯誤
} catch(err) {
// 以下是假設 err 是 Error 類別的實體
console.warn("報錯", (err as Error).message);
}
通常會使用一種型別窄化形式(例: instanceof )來檢查,確保拋出的錯誤是預期的錯誤型別。例:
try {
// 這裡的程式,可能會拋出例外錯誤
} catch(err) {
console.warn("報錯", err instanceof Error ? err.message : err);
}
非 Null 斷言
可以使用 as 或驚嘆號。加上驚嘆號,指的是將型別當中的 null 及 undefined 移除。
以下例子:
// 推斷型別為 Date | undefined
let maybeDate = Math.random() > 0.5 ? undefined : new Date();
// 寫法一:型別斷言為 Date
maybeDate as Date;
// 寫法二:型別斷言為 Date
maybeDate!;
型別斷言注意事項
應儘量避免使用。
斷言與宣告:
使用型別註記來宣告變數的型別,和使用型別斷言是有差別的。型別斷言告知 TypeScript 跳過某些型別檢查。例:
interface Entertainer {
acts: string[];
name: string;
}
// 以下是型別註記,馬上會報錯,少了 acts 屬性
const declared: Entertainer = {
name: "hihi"
};
// 以下使用型別斷言,沒有報錯,但其實不好
const asserted = {
name: "hello"
} as Entertainer;
// 以下兩行,在執行時期,都會報錯
console.log(declared.acts.join(", "));
console.log(asserted.acts.join(", "));
因此,強烈建議使用型別註記或讓 TypeScript 從變數的初始資料來推斷變數的型別。
斷言可指派性:
TypeScript 只允許兩種型別斷言之間的指派,例:
let myValue = "HiHi" as number;
// 以上會報錯,因為 string 轉換為 number 兩種型別並未重疊
如果要將資料從一種型別切換到完全不相關的型別,可以使用雙型別斷言。首先將資料轉換為上層型別(any 或 unknown),然後再轉換為另一個不相關的型別。例:
// 以下正確,但不好
let myValueDouble = "123" as unknown as number;
雙型別斷言建議避免使用,因為表示在附近的程式碼在型別認定上帶有某種不正確性。
常數斷言
常數斷言通常用於表示任何資料:陣列、原始資料、數值,會被視為其自身的常數、不可變的資料。也就是 as const 語法,會將以下三個規則做套用:
- 陣列會被視為 readonly 元組,是不可變陣列。
- 字串會被視為文字,與它們的一般原始型別不相同。
- 物件的屬性會被認為是 readonly。
例:
[0, ""]; // 型別:(number | string)[]
[0, ""] as const; // 型別:readonly [0, ""]
原始型別的文字
型別系統將文字資料解析為特定文字,而非將其擴充到其一般原始型別。
例:
// 型別:() => string
const getName = () => "Hi";
// 型別:() => "Hi"
const getNameConst = () => "Hi" as const;
另個例子:
interface Joke {
quote: string;
style: "story" | "one-liner";
}
function tellJoke(joke: Joke) {
if(joke.style === "one-liner") {
console.log(joke.quote);
}else{
console.log(joke.quote.split("\n"));
}
}
// 型別:{ quote: string; style: "one-liner" }
const narrowJoke = {
quote: "Hello",
style: "one-liner" as const
};
tellJoke(narrowJoke); // 允許
// 型別:{ quote: string; style: string }
const wideObject = {
quote: "Hello2",
style: "one-liner"
};
// 報錯:型別 {quote: string; style: string} 引數不可指派給型別 Joke 的參數
tellJoke(wideObject);
唯讀物件
直接看以下例子的差別:
function abc(preference: "maybe" | "no" | "yes") {
switch(preference){
case "maybe":
return "a";
case "no":
return "b";
case "yes":
return "c";
}
}
// 型別:{ movie: string, standup: string }
const preferencesMutable = {
movie: "maybe",
standup: "yes"
};
// 報錯:型別 string 的引數不可指派給型別 "maybe" | "no" | "yes"
abc(preferencesMutable.movie);
preferencesMutable.movie = "no"; // 允許
// 型別:readonly {readonly movie: "maybe", readonly standup: "yes" }
// 以下這個 preferencesReadonly 就是唯讀物件
const preferencesReadonly = {
movie: "maybe",
standup: "yes"
} as const;
abc(preferencesReadonly.movie); // 允許
// 報錯:因為 movie 是唯讀屬性,所以無法將 "no" 資料指派給 movie
preferencesReadonly.movie = "no";