[TypeScript] 型別修飾符號

上層型別

[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 兩種型別並未重疊

如果要將資料從一種型別切換到完全不相關的型別,可以使用雙型別斷言。首先將資料轉換為上層型別(anyunknown),然後再轉換為另一個不相關的型別。例:

// 以下正確,但不好
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";

留言

發佈留言