作者: Carlos(賓)

  • [TypeScript] 泛型

    有時一段程式碼可能會根據它的呼叫方式來處理各種不同的型別。

    以下這個例子,identity 函式,含有接收任何可能型別的輸入,並回傳相同的輸入作為輸出:

    function identity(input){
      return input;
    }
    
    identity("abc");
    identity(123);
    identity({ quote: "aaa" });

    可將 input 宣告為 any,而函式回傳型別也可以是 any:

    function identity(input: any){
      return input;
    }
    
    let value = identity(123); // value 的型別為 any

    有鑑於 input 是允許任何輸入,需要一種方式來說明 input 型別與函式回傳的型別,兩者之間存在的關係。TypeScript 使用泛型(generics)來抓住型別之間的關係。

    通常用單字母名稱來表示,例 T、U 或駝峰式命名,然後使用左右角括號來宣告,例:someFunction<T>

    泛型函式

    可透過將參數型別的別名放在角括號中,角括號放在參數的小括號之前,就可以將該函式成為泛型。然後這個別名,可用於註記參數型別、回傳型別和函式本體的型別註記。例:

    function identity<T>(input: T) {
      return input;
    }
    
    const numeric = identity("hi"); // 型別:"hi"
    const stringy = identity(123);  // 型別:123

    也可用在箭頭函式:

    const identity = <T>(input: T) => input;
    
    identity(123); // 型別:123

    替函式加上參數型別,允許函式被不同的輸入重複使用,同時仍然保持型別安全,並避免 any 型別。

    明確的泛型呼叫型別

    TypeScript 會將無法推斷的引數型別,預設為 unknown 型別。例:

    function logWrapper<Input>(callback: (input: Input) => void){
      return (input: Input) => {
        console.log("Input:", input);
        callback(input);
      };
    }
    
    // 型別:(input: string) => void
    logWrapper((input: string) => {
      console.log(input.length);
    });
    
    // 型別:(input: unknown) => void
    logWrapper((input) => {
      // 報錯:unknown 型別不存在 length 屬性
      console.log(input.length);
    });

    為了避免預設為 unknown,可使用明確的泛型引數來呼叫函式,該引數型別明確告訴 TypeScript 引數應該是什麼型別。例:

    // 型別:(input: string) => void
    logWrapper<string>((input) => {
      console.log(input.length);
    });

    多個函式參數型別

    以下的 makeTuple 宣告了兩個參數型別,並回傳一個唯讀元組型別的資料,例:

    function makeTuple<First, Second>(first: First, second: Second){
      return [first, second] as const;
    }
    
    // 以下 tuple 的型別:唯讀 [boolean, string]
    let tuple = makeTuple(true, "abc");

    留意:如果一個函式宣告多個參數型別,則對該函式的呼叫,必須明確宣告任何所有泛型型別。例:

    function makePair<Key, Value>(key: Key, value: Value){
      return {key, value};
    }
    
    // 正確,沒有提供引數型別,其型別為:{key: string; value: number}
    makePair("abc", 123);
    
    // 正確,型別為:{key: string; value: number}
    makePair<string, number>("abc", 123);
    // 正確,型別為:{key: "abc"; value: 123}
    makePair<"abc", 123>("abc", 123);
    
    // 報錯:有2個引數型別,但只設定1個
    makePair<string>("abc", 123);

    泛型介面

    介面也可以宣告為泛型。

    以下例子中的 Box 宣告,有一個屬性的 T 參數型別。建立一個宣告為帶有型別 Box 引數的物件,並且需強制符合 inside: T 屬性引數型別:

    interface Box<T> {
      inside: T;
    }
    
    let stringyBox: Box<string> = {
      inside: abc"
    };
    
    let numberBox: Box<number> = {
      inside: 123
    };
    
    let incorrectBox: Box<number> = {
      inside: false
      // 報錯:型別 boolean 不可指派給型別 number
    };

    推斷泛型介面的型別

    interface LinkedNode<Value> {
      next?: LinkedNode<Value>;
      value: Value;
    }
    
    function getLast<Value>(node: LinkedNode<Value>): Value {
      return node.next ? getLast(node.next) : node.value;
    }
    
    // 推斷 value 引數型別:Date
    let lastDate = getLast({
      value: new Date("09-13-1993")
    });
    
    // 推斷 value 引數型別:string
    let lastFruit = getLast({
      next: {
        value: "banana"
      },
      value: "apple"
    });
    
    // 推斷 value 引數型別:number
    let lastMismatch = getLast({
      next: {
        value: 123
      },
      // 錯誤:型別 boolean 不可指派給型別 number
      value: false
    });

    泛型類別

    可宣告任意數量的參數型別,提供給成員使用。例:

    // 宣告了 Key 和 Value 兩個參數型別
    class Secret<Key, Value> {
      key: Key;
      value: Value;
    
      constructor(key: Key, value: Value) {
        this.key = key;
        this.value = value;
      }
    
      getValue(key: Key): Value | undefined {
        return this.key === key ? this.value : undefined;
      }
    }
    
    // 型別:Secret<number, string>
    const storage = new Secret(123, "abc");
    // 型別:string | undefined
    storage.getValue(1987);

    明確的泛型類別型別

    例如上述例子的:

    new Secret(123, "abc");

    TypeScript 可以推斷出它的型別,然而,如果無法從傳遞給其建構函式的參數推斷出引數型別的話,預設為 unknown。

    例:

    class CurriedCallback<Input> {
      #callback: (input: Input) => void;
    
      constructor(callback: (input: Input) => void) {
        this.#callback = (input: Input) => {
          console.log("Input:", input);
          callback(input);
        };
      }
    
      call(input: Input) {
        this.#callback(input);
      }
    }
    
    // 型別:CurriedCallback<string>
    new CurriedCallback((input: string) => {
      console.log(input.length);
    });
    
    // 型別:CurriedCallback<unknown>
    new CurriedCallback((input) => {
      // 報錯:unknown 型別不存在 length 屬性
      console.log(input.length);
    });

    可透過明確的引數型別來避免預設為 uknown,例:

    // 型別:CurriedCallback<string>
    new CurriedCallback<string>((input) => {
      console.log(input.length);
    });

    繼承(擴充)泛型類別

    例:

    class Quote<T> {
      lines: T;
    
      constructor(lines: T) {
        this.lines = lines;
      }
    }
    
    // SpokenQuote 類別替它的基本類別 Quote<T> 提供 string 陣列作為 T 的引數型別
    class SpokenQuote extends Quote<string[]> {
      speak() {
        console.log(this.lines.join("\n"));
      }
    }
    
    new Quote("abc").lines; // 型別:string
    new Quote([1, 2, 3]).lines; // 型別:number[]
    
    new SpokenQuote(["a", "b"]).lines; // 型別:string[]
    
    // 報錯:型別 number 不可指派給型別 string
    new SpokenQuote([1, 2, 3]);

    實現泛型介面

    例:

    // 這個是泛型介面
    interface ActingCredit<Role> {
      role: Role;
    }
    
    class MoviePart implements ActingCredit<string> {
      role: string;
      speaking: boolean;
    
      constructor(role: string, speaking: boolean) {
        this.role = role;
        this.speaking = speaking;
      }
    }
    
    const part = new MoviePart("aaa", true);
    
    part.role; // 型別:string
    
    class IncorrectExtension implements ActingCredit<string> {
      role: boolean;
      // 以上這行會報錯:型別 boolean 不可指派給型別 string
    }

    泛型方法

    例:

    // 類別中宣告了一個泛型型別 Key
    class CreatePairFactory<Key> {
      key: Key;
    
      constructor(key: Key) {
        this.key = key;
      }
    
      createPair<Value>(value: Value) {
        return {key: this.key, value};
      }
    }
    
    // 型別:CreatePairFactory<string>
    const factory = new CreatePairFactory("role");
    
    // 型別:{key: string, value: number}
    const numberPair = factory.createPair(10);
    
    // 型別:{key: string, value: string}
    const stringPair = factory.createPair("bbb");

    靜態泛型型別

    類別當中的靜態成員無權存取任何類別實體的型別資訊。例:

    class BothLogger<OnInstance> {
      instanceLog(value: OnInstance) {
        console.log(value);
        return value;
      }
      // 靜態 staticLog 方法
      static staticLog<OnStatic>(value: OnStatic) {
        // 報錯:靜態成員不得參考類別引數型別
        let fromInstance: OnInstance;
        console.log(value);
        return value;
      }
    }
    
    const logger = new BothLogger<number[]>;
    logger.instanceLog([1, 2, 3]); // 型別:number[]
    
    // 推斷 OnStatic 引數型別:boolean[]
    BothLogger.staticLog([false, true]);
    
    // 明確顯示 OnStatic 引數型別:string
    BothLogger.staticLog<string>("hi");

    泛型型別別名

    TypeScript 當中,每個型別別名可以被給予任意數量的參數型別,例如以下的 Nullish 型別接收一個泛型 T:

    type Nullish<T> = T | null | undefined;

    泛型型別別名通常與函式搭配使用,用來描述泛型函式的型別:

    type CreatesValue<Input, Output> = (input: Input) => Output;
    
    // 型別:(input: string) => number
    let creator: CreatesValue<string, number>;
    
    creator = text => text.length; // 正確
    
    // 報錯:型別 string 不可指派給型別 number
    creator = text => text.toUpperCase();

    泛型可辨識的聯集

    例:

    type Result<Data> = FailureResult | SuccessfulResult<Data>;
    
    interface FailureResult {
      error: Error;
      succeeded: false;
    }
    
    interface SuccessfulResult<Data> {
      data: Data;
      succeeded: true;
    }
    
    function handleResult(result: Result<string>) {
      if(result.succeeded){
        // 進到這裡,result 的型別: SuccessfulResult<string>
        console.log("hihi" + result.data);
      }else{
        // 進到這裡,result 的型別: FailureResult
        console.error("err" + result.error);
      }
    
      // 報錯:型別 Result<string> 沒有屬性 data。
      // 型別 FailureResult 沒有屬性 data。
      result.data;
    }

    泛型修飾符號

    泛型預設值

    給泛型一個預設值。例:

    // Quote 接受一個 T 型別的參數,其型別預設為 string
    interface Quote<T = string> {
      value: T;
    }
    
    // explicit 變數將 T 明確設定為 number
    let explicit: Quote<number> = {
      value: 123
    };
    
    // implicit 變數,其 T 型別預設為 string
    let implicit: Quote = {
      value: "aaa"
    };
    
    
    let mismatch: Quote = {
      // 報錯:型別 number 不可指派給型別 string
      value: 123
    };

    需留意的是,所有預設參數型別必須放在宣告清單中的最後位置。

    受限制的泛型型別

    限制參數型別的語法,是將 extends 關鍵字放在參數型別的名稱之後,然後是限制它的型別。

    例:

    interface WithLength {
      length: number;
    }
    
    function logWithLength<T extends WithLength>(input: T){
      console.log("Length: " + input.length);
      return input;
    }
    
    logWithLength("abc"); // 型別:string
    logWithLength([false, true]); // 型別:boolean[]
    logWithLength({ length: 123 }) // 型別:{length: number}
    
    logWithLength(new Date()); // 報錯:缺少 length 屬性

    keyof 和限制參數型別

    例:

    function get<T>(container: T, key: keyof T) {
      return container[key];
    }
    
    const roles = {
      favofite: "Fargo",
      others: ["a", "b", "c"]
    };
    
    const found = get(roles, "favorite"); // 型別:string | string[]

    Promise

  • [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";

  • [TypeScript] 類別

    類別方法

    例:

    class Greeter {
      greet(name: string) {
        console.log(name + ", hi!");
      }
    }
    
    new Greeter().greet("tt"); // 正確
    
    new Greeter().greet(); // 報錯:應有1個引數。

    建構函式的例子:

    class Greeted {
      constructor(message: string) {
        console.log("Hi, " + message + "!");
      }
    }
    
    new Greeted("abc"); // 正確
     
    new Greeted(); // 報錯:應有1個引數

    類別屬性

    類別屬性使用與介面宣告的語法相同:在屬性名稱後面緊接著選擇的型別註記。

    TypeScript 不會從建構函式中,推斷類別中可能存在的成員。

    例:

    class FieldTrip {
      // 類別屬性:明確宣告 destination 為 string
      destination: string;
    
      constructor(destination: string) {
        this.destination = destination; // 正確
        
        // 報錯:沒有 abc 這個屬性
        this.abc = destination;
      }
    }
    
    const trip = new FieldTrip("plane");
    trip.destination; // 正確
    
    trip.abc; // 報錯

    函式屬性

    先瞭解有 JavaScript 當中的類別函式,有兩種寫法(方法和屬性)。

    語法一:

    class WithMethod {
      myMethod(){
        console.log("abc");
      }
    }
    
    // 判斷結果是 true,所有的類別實體都使用相同的函式定義。
    new WithMethod().myMethod === new WithMethod().myMethod;

    語法二:

    class WithProperty {
      myProperty: () => {
        console.log("abc");
      }
    }
    
    // 判斷結果是 false,這將會為每個類別實體建立一個新函式
    new WithProperty().myProperty === new WithProperty().myProperty;

    可以使用與類別方法和獨立函式相同的語法:

    class abcde {
      takesParameters = (input: boolean) => input ? "Yes" : "No";
    }
    
    const instance = new abcde();
    instance.takesParameters(true); // 正確
    
    // 報錯:型別 number 的引數,不可指派給型別 boolean 的參數
    instance.takesParameters(123);

    初始化檢查

    開啟嚴格(建議開啟 strictPropertyInitialization)的編譯器設定的話,可以讓 TypeScript 檢查建構函式中,每個屬性宣告的型別,如下情況:

    class WithValue {
      immediate = 0; // 正確
      later: number; // 正確(因為有在constructor中設定初始值)
      mayBeundefined: number | undefined; // 正確(允許 undefined)
      
      // 報錯:屬性 unused 沒有設定初始值,在 constructor 中也沒有明確給定資料
      unused: number;
    
      constructor() {
        this.later = 1;
      }
    }

    明確指定的屬性

    如果確保一個屬性不需要套用嚴格的初始化檢查的話,可以在屬性名稱之後加一個驚嘆號,則可關閉檢查。

    這樣做會向 TypeScript 斷言,該屬性將在第一次使用之前,會被指派一個非 undefined 的資料。

    例(避免使用,建議應用 constructor):

    class ActivitiesQueue {
      pending!: string[]; // 正確,若沒有加驚嘆號,就會報錯。
    
      initialize(pending: string[]) {
        this.pending = pending;
      }
    
      next() {
        return this.pending.pop();
      }
    }
    
    const abc = new ActivitiesQueue();
    abc.initialize(["a", "b", "c"]);
    abc.next();

    可選擇的屬性

    可以在類別屬性名稱之後,加上問號,來代表該屬性是可選擇的:

    class MissingInitializer {
      property?: string;
    }
    
    // 正確
    let a = new MissingInitializer().property?.length;
    console.log(a); // 會印出 undefined
    
    // 報錯:Object is possibly 'undefined'.
    new MissingInitializer().property.length;

    唯讀屬性

    類別可以在宣告名稱之前加上 readonly 關鍵字來將屬性宣告成唯讀,宣告為 readonly 的屬性,只能在宣告它們的地方或在建構函式中,指派初始資料。例:

    class Quote {
      readonly text: string;
    
      constructor(text: string) {
        // 在建構函式中,設定初始資料,是可以的。
        this.text = "";
      }
    
      emphasize() {
        // 報錯:因為 text 是唯讀屬性。
        this.text += "a";
      }
    }
    const quote = new Quote("aaa");
    // 以下這行報錯,因為 text 是唯讀屬性,無法設定資料。
    Quote.text = "hihi";

    另外一種情況,宣告為具有基本初始資料的唯讀屬性與其它屬性相比,它們可能被推斷為經過資料窄化的字面型別。例:

    class RandomQuote {
      readonly a: string = "aaa";
      readonly b = "bbb";
    
      constructor() {
        if(Math.random() > 0.5){
          this.a = "aaa change"; // 正確
    
          // 以下這行報錯:型別 "bbb change" 不可指派給型別 "bbb"
          this.b = "bbb change";
        }
      }
    }
    const quote = new RandomQuote();
    quote.a; // 型別:string
    quote.b; // 型別:"bbb"

    作為型別的類別

    下例中,有一個 teacher 變數,型別為 Teacher 類別:

    class Teacher {
      sayHello() {
        console.log("Hello");
      }
    }
    
    let teacher: Teacher;
    teacher = new Teacher(); // 正確
    
    // 報錯:型別 string 不可指派給型別 Teacher。
    teacher = "wow";

    另一個例子:withSchoolBus 接受一個 SchoolBus 型別的參數。透過任何具有 () => string[] 型別的 getAbilities 屬性來達成,例:

    class SchoolBus {
      getAbilities() {
        return ["a", "b"];
      }
    }
    
    // 參數要求類別型別,較少見
    function withSchoolBus(bus: SchoolBus) {
      console.log(bus.getAbilities());
    }
    
    withSchoolBus(new SchoolBus()); // 正確
    
    // 正確
    withSchoolBus({
      getAbilities: () => ["c"]
    });
    
    // 以下會報錯:型別 number 不可指派給型別 string[]
    withSchoolBus({
      getAbilities: () => 123
    });

    類別和介面

    TypeScript 允許在類別名稱之後加上 implements 關鍵字,後面緊接著所遵循的介面,代表介面中的每一個部分都要被實作。

    例:

    interface Learner {
      name: string;
      study(hours: number): void;
    }
    
    class Student implements Learner {
      name: string;
    
      constructor(name: string) {
        this.name = name;
      }
    
      study(hours: number) {
        console.log("abc");
      }
    }
    
    class Slacker implements Learner {
      // 報錯:類別 Slacker 不正確地實作介面 Learner。
      // 型別 Slacker 缺少屬性 study。
      name = "Rocky";
    }

    實作介面只是一種安全檢查。它不會將介面當中的任何成員,複製到類別中。

    多個實作的介面

    在 TypeScript 中,允許將類別宣告為實作多個介面,以逗號區隔。

    例:

    interface Graded {
      grades: number[];
    }
    
    interface Reporter {
      report: () => string;
    }
    
    class ReportCard implements Graded, Reporter {
      grades: number[];
    
      constructor(grades: number[]) {
        this.grades = grades;
      }
    
      report() {
        return this.grades.join(", ");
      }
    }
    
    // 報錯:類別 Empty 不正確地實作介面 Graded。
    //   型別 Empty 缺少屬性 grades。
    // 報錯:類別 Empty 不正確地實作介面 Reporter。
    //   型別 Empty 缺少屬性 report。
    class Empty implements Graded, Reporter {
    
    }

    在開發過程中,可能有某些介面的定義,會使得某一個類別不可能同時實作這兩者,將導致該類別至少出現一個型別錯誤,所以需留意。

    擴充(繼承)類別

    基本類別(base class)上宣告的任何方法或屬性,都可在子類別(也稱做衍生類別)使用。

    例:

    class Teacher {
      teach() {
        console.log("teach");
      }
    }
    
    // Teacher 是基本類別(base class),StudentTeacher 是衍生類別或稱做子類別
    class StudentTeacher extends Teacher {
      learn() {
        console.log("learn");
      }
    }
    
    const teacher = new StudentTeacher();
    teacher.teach(); // 正確(在 base class 有定義)
    teacher.learn(); // 正確(在 subclass 有定義)
    
    // 報錯:型別 StudentTeacher 沒有 other 屬性
    teacher.other();

    指派性的擴充

    例:

    class Lesson {
      subject: string;
    
      constructor(subject: string) {
        this.subject = subject;
      }
    }
    
    class OnlineLesson extends Lesson {
      url: string;
    
      constructor(subject: string, url: string) {
        super(subject); // 會執行基本類別(父類別)
        this.url = url;
      }
    }
    
    let lesson: Lesson;
    lesson = new Lesson("coding"); // 正確
    lesson = new OnlineLesson("coding", "abc"); // 正確
    
    let online: OnlineLesson;
    online = new OnlineLesson("coding", "def"); // 正確
    
    // 報錯:型別 Lesson 缺少 url 屬性,但型別 OnlineLesson 必須有 url 屬性
    online = new Lesson("coding");

    覆寫建構函式

    與原生 JavaScript 一樣,TypeScript 不需要子類別來定義自己的建構函式。沒有自己子類別的建構函式,會隱函使用基本類別(父類別)的建構函式。

    在 JavaScript 中,如果子類別確實宣告自己的建構函式,這麼必須透過 super 關鍵字呼叫其基本類別的建構函式

    例:

    class GradeAnnouncer {
      message: string;
    
      constructor(grade: number) {
        this.message = grade >= 65 ? "a" : "b";
      }
    }
    
    class PassingAnnouncer extends GradeAnnouncer {
      constructor() {
        super(100);
      }
    }
    
    class FailingAnnouncer extends GradeAnnouncer {
      // 報錯:衍生類別的建構函式必須執行 super 函式
      constructor() {
        
      }
    }

    根據 JavaScript 規則,子類別的建構函式必須在存取 this 或 super 之前,呼叫基本建構函式。如果 TypeScript 在 super() 之前看到 this 或 super 被存取,系統將報告型別錯誤。例:

    class GradesTally {
      grades: number[] = [];
    
      aaa(...grades: number[]) {
        this.grades.push(...grades);
        return this.grades.length;
      }
    }
    
    class ContinuedGradesTally extends GradesTally {
    
      constructor(previousGrades: number[]) {
        this.grades = [...previousGrades];
        // 上面這行報錯:因為必須先呼叫 super,才能存取衍生類別中建構函式的 this
    
        super();
      }
    
    }

    覆寫方法

    子類別可以重新宣告與基本類別相同名稱的新方法。只要子類別上的方法可以指派給基本類別。

    例:

    class GradeCounter {
      countGrades(grades: string[], letter: string) {
        // 回傳 number
        return grades.filter(grade => grade === letter).length;
      }
    }
    
    class FailureCounter extends GradeCounter {
      // 此覆寫是ok的
      countGrades(grades: string[]) {
        return super.countGrades(grades, "F");
      }
    }
    
    class Any FailureChecker extends GradeCounter {
      // 此覆寫會失敗,因為回傳的型別不一致
      countGrades(grades: string[]) {
        // 回傳 boolean
        return super.countGrades(grades, "F") !== 0;
      }
    }
    
    const counter: GradeCounter = new AnyFailureChecker();
    
    // 預期型別:number
    // 實際型別:boolean
    const count = counter.countGrades(["A", "C", "F"]);

    覆寫屬性

    子類別也可以用相同的名稱,明確重新宣告其基本類別的屬性,只要新型別可以指派給基本類別上的型別。

    在以下範例中,基本類別 Assignment 將 grade 宣告為 number | undefined,而子類別 GradedAssignment 將其宣告為必須存在的 number:

    class Assignment {
      // number | undefined
      grade?: number;
    }
    
    class GradedAssignment extends Assignment {
      // 覆寫成功,此型別為 number
      grade: number;
    
      constructor(grade: number) {
        super();
        this.grade = grade;
      }
    }

    下例是錯誤的:

    class NumericGrade {
      value = 0;
    }
    
    class VagueGrade extends NumericGrade {
      // 以下覆寫失敗,因為可能會是 string 型別
      value = Math.random() > 0.5 ? 1 : "aaa";
    }
    
    const instance: NumericGrade = new VagueGrade();
    
    // 預期型別:number
    // 實際型別:number | string
    instance.value;

    抽象類別

    在專案中,有時會建立一個本身不宣告方法的實作,期望提供一個基本類別作為子類別實作的參考。將類別標記為抽象,可將 TypeScript 的 abstract 關鍵字,加在類別名稱或任何打算抽象的方法之前。

    以下範例中 School 類別及其 getStudentTypes 方法被標記為 abstract。因此,它的子類別 Preschool 和 Absence 應該實作 getStudentTypes:

    abstract class School {
      readonly name: string;
    
      constructor(name: string) {
        this.name = name;
      }
    
      abstract getStudentTypes(): string[];
    }
    
    class Preschool extends School {
      getStudentTypes() {
        return ["abc"];
      }
    }
    
    // 報錯:類別 Absence 未實作從類別 School 繼承而來的 getStudentTypes
    class Absence extends School {
    
    }

    抽象(abstract)類別不能直接實體化,因為沒有對其實作,僅只假設可能確實存在某些方法的定義。只有非抽象類別能實體化。例:

    let school: School;
    school = new Preschool("aaa"); // 正確
    
    // 報錯:無法建立抽象類別的執行個體
    school = new School("bbb");

    成員的可見性

    包括 JavaScript,若類別成員名稱,前面有#做開頭,則是標記為 private 類別成員。private 類別成員只能由該類別的實體做存取。如果類別之外的程式碼試圖存取的話,會報錯。

    TypeScript 的成員可見性,是透過增加以下關鍵字來達成:

    • public:允許任何人、任何地方存取。
    • protected:只允許類別本身及其子類別存取。
    • private:只允許類別本身存取。

    以上關鍵字只存在於型別系統中。當程式碼編譯為 JavaScript 時,都會被刪除。

    例:

    class Base {
      isPublicImplicit = 0; // 這會隱含為 public
      public isPublicExplicit = 1;
    
      protected isProtected = 2;
    
      private isPrivate = 3;
      #truePrivate = 4; // 前面有 # 字號,代表真正的私有屬性
    }
    
    class Subclass extends Base {
      examples() {
        this.isPublicImplicit; // 允許
        this.isPublicExplicit; // 允許
        this.isProtected; // 允許
    
        // 報錯:isPrivate 是私有屬性,只可從類別 Base 中存取
        this.isPrivate;
    
        // 報錯:因為屬性 #truePrivate 具有私人識別碼,所以無法在類別 Base 外存取這個屬性
        this.#truePrivate;
      }
    }
    
    new Subclass().isPublicImplicit; // 允許
    new Subclass().isPublicExplicit; // 允許
    
    // 報錯:isProtected 是受保護的屬性,只可從類別 Base 及其子類別中存取。
    new Subclass().isProtected;
    
    // 報錯:isPrivate 是私有屬性,只可從類別 Base 中存取。
    new Subclass().isPrivate;

    註:# 字號是表示私有欄位,它在執行時 JavaScript 中才是真正的私有,因為 # 字號是 JavaScript 中本來就有的語法。

    可見性修飾符號可以和 readonly 一起使用。要將成員宣告為 readonly 並且為可見的,那麼可見性會是第一順位被考慮的。例:

    class TwoKeywords {
      private readonly name: string;
    
      constructor() {
        this.name = "aaa"; // 允許
      }
    
      log() {
        console.log(this.name); // 允許
      }
    }
    
    const two = new TwoKeywords();
    
    // 報錯:name 是私有屬性,只可從類別 TwoKeywords 中存取。
    // 報錯:name 是唯讀,所以無法將資料指派給 name。
    two.name = "ttt";

    留意:TypeScript 不允許將可見性關鍵字與 JavaScript 的新 # 私有欄位混合使用。

    靜態(static)欄位修飾符號

    JavaScript 允許在類別本身,使用 static 關鍵字宣告成員。例:

    class Question {
      protected static readonly answer: "bash";
      protected static readonly prompt = "aaa";
    
      guess() {
        const answer = Question.prompt; // 允許
      }
    }
    
    // 以下會報錯:answer 是 protected,只可從類別 Question 及其子類別存取
    Question.answer;

  • [TypeScript] 介面

    型別別名與介面

    型別別名與介面非常相似,使用物件型別別名來描述物件具有 born: number 和 name: string 的語法:

    type Poet = {
      born: number;
      name: string;
    };

    然而,如果是使用介面,語法是:

    interface Poet {
      born: number;
      name: string;
    }

    喜歡分號的開發者,通常將分號放在型別別名之後,而不是在介面之後。

    然後就可以使用以下語法來設定 valueLater 的型別或介面為 Poet:

    let valueLater: Poet;

    然而,介面和型別別名之間,有幾個區別:

    • 介面可以「合併」,進行擴充。
    • 介面可用於類別宣告做型別結構的檢查,而型別別名不能。
    • 使用介面,可以讓 TypeScript 型別檢查變得更快速:在內部宣告一個可以更容易暫存的命名型別,而非像型別別名那樣,動態複製和貼上檢查新物件字面資料。
    • 因為介面被認為是命名物件,而不是未命名物件的別名,所以它們的錯誤訊息在一些情況下,更易閱讀。

    屬性型別

    可選擇的屬性

    可透過問號,來表示介面的屬性是可選擇的:

    interface Book {
      author?: string; // author 屬性是可選的
      pages: number;
    }
    
    // 正確
    const ok: Book = {
      author: "abc",
      pages: 80
    };
    
    // 以下報錯:Property 'pages' is missing in type '{ author: string; }' but required in type 'Book'.
    const missing: Book = {
      author: "def"
    };

    唯讀屬性

    可以在屬性名稱之前,加上 readonly 關鍵字,來表示該屬性是唯讀的,不能重新指派新的資料。

    例:

    // Page 介面的 text 屬性,可存取然後會回傳字串,但如果指派新資料給 text 的話,會導致型別錯誤
    interface Page {
      readonly text: string;
    }
    
    function read(page: Page) {
      console.log(page.text); // 正確
    
      // 報錯:因為 text 是唯讀屬性
      page.text += "!";
    }
    
    const myPage: Page = {
      text: "hi"
    };
    
    // 報錯:因為 text 是唯讀屬性
    myPage.text += "!";

    函式和方法

    TypeScript 允許將介面成員,宣告為函式的兩個方式:

    • 方法(Method)語法:宣告介面的成員,作為物件成員呼叫的函式,例如 member(): void。
    • 屬性(Property)語法:宣告介面的成員,為獨立函式,例如 member: () => void。

    例如以下:method 和 property 成員,都是可以不帶參數呼叫,並回傳 string 的函式:

    interface HasBothFunctionTypes {
      property: () => string;
      method(): string;
    }
    
    const hasBoth: HasBothFunctionTypes = {
      property: () => "",
      method() {
        return "";
      }
    };
    
    hasBoth.property(); // 正確
    hasBoth.method();   // 正確

    也可以使用問號來表示可選擇(optional),例:

    interface HasBothFunctionTypes {
      property?: () => string;
      method?(): string;
    }

    方法和屬性宣告大多可以互換使用,一個主要的差別是:

    • 方法不能宣告為 readonly,但屬性可以。

    呼叫特徵

    宣告介面和物件型別會具有呼叫特徵(call signatures)。呼叫特徵看起來類似於函式型別,但用冒號取代箭頭,例:

    type FunctionAlias = (input: string) => number;
    
    interface CallSignature {
      (input: string): number;
    }
    
    // 型別:(input: string) => number
    const typedFunctionAlias: FunctionAlias = (input) => input.length; // 正確
    
    // 型別:(input: string) => number
    const typedCallSignature: CallSignature = (input) => input.length; // 正確

    下例,keepsTrackOfCalls 函式宣告中,讓 count 屬性具有 number 型別,使其可指派給 FunctionWithCount 介面:

    interface FunctionWithCount {
      count: number;
      (): void;
    }
    
    let hasCallCount: FunctionWithCount;
    
    function keepsTrackOfCalls() {
      keepsTrackOfCalls.count += 1;
      console.log("已被執行" + keepsTrackOfCalls.count + "次。");
    }
    
    keepsTrackOfCalls.count = 0;
    
    hasCallCount = keepsTrackOfCalls; // 正確
    
    function doesNotHaveCount() {
      console.log("No idea!");
    }
    
    // 報錯:型別 () => void 缺少屬性 count,型別 FunctionWithCount 必須具有該屬性。
    hasCallCount = doesNotHaveCount;

    索引特徵

    TypeScript 提供了一種稱為索引特徵(index signature)的語法,用來表示可以接受任何的字串 key,並回傳該 key 下的特定型別。最常用於字串鍵值。

    例如以下寫法:

    // WordCounts 是一個可以接受任何字串 key,然後都要回傳 number 型別的一個介面
    interface WordCounts {
      [i: string]: number;
    }
    
    const counts: WordCounts = {};
    counts.apple = 0;  // 正確
    counts.banana = 1; // 正確
    
    // 報錯:型別 boolean 不能指派給型別 number
    counts.cherry = false;

    索引特徵會有一個問題:無論存取什麼屬性,預設上,物件都會回傳一個資料。例:

    interface DatesByName {
      [i: string]: Date;
    }
    
    const a: DatesByName = {
      b: new Date("1 January 1818")
    };
    
    a.b; // 型別 Date
    console.log(a.b.toString()); // 正確
    
    a.c; // 型別 Date,但執行時為 undefined
    console.log(a.c.toString()); // 在型別系統中會是正確的,但執行時期會報錯:無法讀取屬性 toString

    混合的屬性和索引特徵

    介面能夠包含明確的屬性命名和包羅萬象的 string 索引特徵。有一點要留意:每個命名屬性的型別,必須可以指派給一般的索引特徵型別。所以可以混合,就像告訴 TypeScript 命名屬性制訂出更具體的型別,而任何其它屬性都可以回退到索引特徵的型別。例:

    // HistoricalNovels 介面,宣告所有屬性都是 number 型別,但 Oroonoko 屬性必須存在
    interface HistoricalNovels {
      Oroonoko: number;
      [i: string]: number;
    }
    
    // 正確
    const novels: HistoricalNovels = {
      Outlander: 1991,
      Oroonoko: 1688
    };
    
    // 報錯,型別 {Outlander: number} 缺少屬性 Oroonoko。
    const missingOroonoko: HistoricalNovels = {
      Outlander: 1991
    };

    再看以下例子:

    // ChapterStarts 介面,宣告 preface 屬性必須為 0,而其它屬性為 number 型別。
    interface ChapterStarts {
      preface: 0;
      [i: string]: number;
    }
    
    const a: ChapterStarts = {
      preface: 0,
      night: 1,
      shopping: 5
    };
    
    // 以下報錯:型別 1 不可指派給型別 0。
    const b: ChapterStarts = {
      preface: 1
    };

    數字索引特徵

    JavaScript 會預設將 key 轉換為字串。然而,也可以使用數值當作物件的 key。然而在 TypeScript 中,該如何撰寫呢:

    // 允許,因為 string 可指派給 string | undefined。
    interface MoreNarrowNumbers {
      [i: number]: string;
      [i: string]: string | undefined;
    }
    
    // 正確
    const mixesNumbersAndStrings: MoreNarrowNumbers = {
      0: "",
      key1: "",
      key2: undefined
    };
    
    // 報錯:number 索引型別 string | undefined 無法指派給 string 索引型別 string。
    interface abc {
      [i: number]: string | undefined;
      [i: string]: string;
    }

    巢狀介面

    介面也可以寫成巢狀的形式。例:

    interface Novel {
      author: {
        name: string;
      };
      setting: Setting;
    }
    interface Setting {
      place: string;
      year: number;
    }
    
    let myNovel: Novel;
    
    // 正確
    myNovel = {
      author: {
        name: "aaa"
      },
      setting: {
        place: "bbb",
        year: 1812
      }
    };
    
    let myNovel2: Novel;
    myNovel2 = {
      author: {
        name: "aaa"
      },
      setting: {
        // 錯誤:型別 {place: string} 缺少屬性 year
        place: "bbb"
      }
    };

    介面繼承

    一個介面,可以包含另一個介面的所有相同成員,並且還會加入一些額外的功能。

    TypeScript 允許一個介面 繼承或稱做擴充(extend) 另一個介面,會複製另一個介面的所有成員。

    語法及範例:

    interface Writing {
      title: string;
    }
    // Novella 介面繼承 Writing 介面。
    // Novella 稱做衍生介面;Writing 稱做基本介面。
    interface Novella extends Writing {
      pages: number;
    }
    
    // 正確
    let myNovella: Novella = {
      pages: 195,
      title: "aaa"
    };
    
    // 報錯:少了 pages 屬性
    let abc: Novella = {
      title: "aaa"
    };
    
    // 報錯:只可以指定已知的屬性,所以不能有 author 屬性
    let def: Novella = {
      pages: 195,
      title: "aaa",
      author: "John"
    };

    覆寫(override)的屬性

    TypeScript 的型別檢查,將強制被覆寫的屬性,必須可以指派給它的基本屬性,這樣做是為了確保衍生介面型別的實作,可保持指派給基本介面型別。

    例:

    interface WithNullableName {
      name: string | null;
    }
    
    // 正確
    interface WithNonNullableName extends WithNullableName {
      name: string;
    }
    
    // 會報錯:屬性 name 的型別不相容
    interface WithNumericName extends WithNullableName {
      name: number | string;
    }

    擴充(繼承)多個介面

    在衍生介面名稱之後的 extends 關鍵字,可以使用多個數量的介面名稱,用逗號分隔即可。

    例:

    interface a {
      giveA(): number;
    }
    
    interface b {
      giveB(): string;
    }
    
    interface c extends a, b {
      giveC(): number | string;
    }
    
    function my_func(instance: c) {
      instance.giveC(); // 型別 number | string
      instance.giveA(); // 型別 number
      instance.giveB(); // 型別 string
    }

    介面合併

    如果兩個介面以相同的名稱宣告在同一個 scope 當中,它們將會自動合併。(建議少用此方式)

    例:

    interface Merged {
      fromFirst: string;
    }
    
    interface Merged {
      fromSecond: number;
    }
    
    // 以上等同於:
    // interface Merged {
    //   fromFirst: string;
    //   fromSecond: number;
    // }

    成員命名上的衝突

    例:

    interface MergedProperties {
      same: (input: boolean) => string;
      different: (input: string) => string;
    }
    
    interface MergedProperties {
      same: (input: boolean) => string; // 正確
      // 以下會報錯,因為跟前面的衝到了。
      different: (input: number) => string;
    }

    合併的介面可以定義具有相同名稱和不同特徵的方法。這樣做會為該方法建立一個函式重載(overload)。

    例:

    interface MergedMethods {
      different(input: string): string;
    }
    
    interface MergedMethods {
      different(input: number): string; // 正確
    }

  • [生活] 2025(不定期更新)

    記錄今年的期望:

    工作

    1、撰寫 API。

    2、撰寫 Docker 教材。

    3、經營 部落格及 YouTube。

    投資

    1、資產配置:股票、債券、現金、比特幣、房產。

    2、累積被動收入。

    3、保險:醫療險、壽險。

    感情

    1、遇到對的人。

    運動

    1、渣打台北公益馬拉松,跑 13 公里。(01/12)

    2、國家地理路跑,跑 9 公里。(04/27)

    語言

    1、英文(04/13 TOEIC)

    料理

    其它

    1、葡萄酒 L2。

    2、無人機。

    3、吉他。

  • [生活] 2025 年渣打臺北公益馬拉松

    [生活] 2025 年渣打臺北公益馬拉松

    參與了 2025 年 01 月 12 日(日)的渣打銀行辦的臺北公益馬拉松路跑活動。有全馬(42.195公里)、半馬(21.0975公里)、13公里、3公里,我選擇的是跑 13 公里。

    時間總共花了 01:43:24。

    這是我第二次參加路跑活動,第一次是跑101的垂直馬拉松,都超累。

    這次跑的地方是從總統府跑到大佳河濱公園,下圖是使用 NikeRunClub APP 的路徑截圖:

    終點在大佳河濱公園:

    完賽獎牌

    成績證明

    成績證明

  • [TypeScript] 陣列

    TypeScript 遵守每個陣列保持單一資料型別,最佳作法是記住陣列中最初的資料型別,並且只允許陣列對此種數據資料進行操作。

    陣列型別

    例如以下:

    // arrayOfNumbers 是一個存「數值」資料的「陣列」。
    let arrayOfNumbers: number[];
    
    arrayOfNumbers = [4, 8, 15];

    註:陣列型別也可以使用類似 Array<number> 的語法來撰寫,稱為泛型類別(class generics),大多數開發人員偏好更簡易的 number[] 語法。

    陣列與函數型別

    例如以下:

    // createStrings 變數是一個函式,會回傳資料為字串的陣列
    let createStrings: () => string[];
    
    // stringCreators 變數會是一個陣列,陣列當中的每個資料,會是一個回傳字串的函式
    let stringCreators: (() => string)[];

    聯集的陣列型別

    可以使用聯集型別來表示陣列中每個元素是不同的型別。仍需要使用中括號來註記陣列的內容部分與聯集型別。

    例如以下:

    // 型別是 number 或 string 的陣列
    let stringOrArrayOfNumbers: string | number[];
    
    // 型別是一個陣列,陣列裡的資料會是 number 或 string
    let arrayOfStringOrNumbers: (string | number)[];

    另外,從陣列中的資料,也可推斷所有可能型別的聯集,例:

    // 型別為 (string | undefined)[]
    const namesMaybe = ["aaa", "bbb", undefined];

    演變成 any 的陣列

    如果沒有在最初空陣列上,為變數設定所包含的型別註記,TypeScript 會將陣列視為演變的 any[],這代表可以接收任何內容,我們並不喜歡 any[] 陣列因為這會允許增加不確定的資料,進而否定了 TypeScript 型別檢查的優點。例如:

    // 此時型別為 any[]
    let values = [];
    
    // 此時型別變為 string[]
    values.push("");
    
    // 此時型別再變為 (number | string)[]
    values[0] = 0;

    多維陣列

    二維陣列或陣列的陣列,將有兩個中括號([]) 符號:

    let arrayOfArraysOfNumbers: number[][];
    // 上面這行,也可以寫這樣:
    let arrayOfArraysOfNumbers: (number[])[];
    
    arrayOfArraysOfNumbers = [
      [1, 2, 3],
      [2, 4, 6],
      [3, 6, 9]
    ];

    一個三維度陣列,將具有三個中括號([]),以此類推。

    陣列成員

    第一個例子:檢索陣列成員並回傳該陣列型別的元素,以下 defenders 陣列的型別是 string[],所以 defender 是一個字串:

    // defenders 的型別是 string[]
    const defenders = ["a", "b"];
    
    // defender 的型別是 string
    const defender = defenders[0];

    第二個例子:聯集型別陣列的成員,本身就是相同的型別:

    // abc 的型別為 (string | Date)[]
    const abc = ["a", new Date(1999, 1, 2)];
    
    // a 的型別會是 Date | string
    const a = abc[0];

    展開和剩餘

    展開

    陣列可以使用 … 展開運算符號。TypeScript 分析陣列結果,將可以包含來自任何陣列輸入的資料。

    例如以下:已知聯集陣列包含 string 型別和 number 型別的資料,因此推斷其型別為 (string | number)[]:

    // 型別為 string[]
    const soldiers = ["a", "b", "c"];
    
    // 型別為 number[]
    const soldierAges = [1, 2, 3];
    
    // 型別為 (string | number)[]
    const conjoined = [...soldiers, ...soldierAges];

    剩餘參數

    例如以下: …names 參數只接受 string 資料的陣列。

    // names 的型別為 string[]
    function logWarriors(greeting: string, ...names: string[]){
      for(const name of names){
        console.log(greeting + ", " + name + "!");
      }
    }
    
    const warriors = ["a", "b", "c"];
    logWarriors("Hello", ...warriors); // 允許
    
    const birthYears = [1, 2, 3];
    // 以下會報錯:型別 number 的引數不可指派給型別 string 的參數
    logWarriors("Born in", ...bithYears);

    元組

    理論上,JavaScript 陣列可以是任意大小,但是,若可以使用固定大小的陣列,有時會較好,所以這種固定大小的陣列,就稱為元組(tuple)

    元組陣列在每個索引位置都有一個特定的已知型別,它可能比陣列所有可能成員的聯集型別更加具體化。

    宣告元組型別的語法,看起來像一個陣列字面值,但使用型別代替了元素值。

    例:

    // 宣告為一個元組型別,索引值為 0 是 number,索引值為 1 是 string
    let yearAndWarrior: [number, string];
    
    yearAndWarrior = [50, "a"]; // 允許
    
    // 以下會報錯,型別 boolean 不可指派給型別 number
    yearAndWarrior = [false, "b"];
    
    // 以下會報錯,型別 [number] 不可指派給型別 [number, string],來源有一個元素,但目標需要 2 個。
    yearAndWarrior = [530];

    元組通常與 JavaScript 中的解構一起使用,才能夠一次指派多個值,例:

    // year 型別會推斷是 number
    // warrior 型別會推斷是 string
    let [year, warrior] = Math.random() > 0.5 ? [340, "a"] : [123, "b"];

    元組的指派性

    元組型別被 TypeScript 視為比可變長度陣列,更為具體化的型別。這表示可變長度陣列型別,不能指派給元組型別

    以下例子:

    // 型別會推斷為 (boolean | number)[]
    const pairData = [false, 123];
    
    // 以下會報錯:型別 (boolean | number)[] 不可指派給型別 [boolean, number],目標需要2個元素,但來源的元素可能較少。
    const pairTupleLoose: [boolean, number] = pairData;

    再看下一個例子:

    const tupleThree: [boolean, number, string] = [false, 123, "a"];
    
    const tupleTwoExact: [boolean, number] = [tupleThree[0], tupleThree[1]]; // 允許
    
    // 以下會報錯:型別 [boolean, number, string] 不可指派給型別 [boolean, number]
    const tupleTwoExtra: [boolean, number] = tupleThree;

    元組視為剩餘參數

    因為元組具有長度資訊元素型別,被視為更豐富且具體化型別資訊的陣列,所以對於要傳遞給函式的參數特別有用。

    例:

    function logPair(name: string, value: number) {
      console.log(name + " has " + value);
    }
    
    const pairArray = ["a", 1];
    // 會報錯:擴展引數必須具有元組型別或傳遞給剩餘參數
    logPair(...pairArray);
    
    const pairTupleIncorrect: [number, string] = [1, "b"];
    // 會報錯:型別 number 的引數不可指派給型別 string 參數
    logPair(...pairTupleIncorrect);
    
    const pairTupleCorrect: [string, number] = ["c", 1];
    logPair(...pairTupleCorrect); // 正確

    另外一個例子,若想使用剩餘參數元組,可與陣列混合。例:

    function logTrio(name: string, value: [number, boolean]) {
      console.log(name + " has " + value[0] + " " + value[1]);
    }
    
    const trios: [string, [number, boolean]][] = [
      ["a", [1, true]],
      ["b", [2, false]],
      ["c", [3, false]]
    ];
    
    trios.forEach(trio => logTrio(...trio)); // 正確
    
    // 以下會報錯
    trios.forEach(logTrio);

    元組推導

    TypeScript 通常將建立的陣列,視為可變長度陣列,而不是元組。如果解析到一個陣列,被使用作為變數的初始值或函式的回傳值,那麼將會假定成一個大小靈活的陣列,而非一個固定大小的元組。

    例:

    // 回傳型別:(string | number)[]
    function firstCharAndSize(input: string) {
      return [input[0], input.length];
    }
    
    // firstChar 型別:string | number
    // size 型別:string | number
    const [firstChar, size] = firstCharAndSize("a");
    

    TypeScript 中有兩種常見的方式,用來指定一個變數,應該是一個更具體的元組型別,即:「明確的元組型別」、「常數斷言元組」。

    明確的元組型別

    元組型別可以用在型別註記中,例如函式的回傳型別註記。如果函式被宣告為回傳元組型別,並且回傳陣列字面形式,則該陣列將被推斷為元組,例:

    // 回傳型別:[string, number]
    function firstTest(input: string): [string, number]{
      return [input[0], input.length];
    }
    
    // firstChar 型別:string
    // size 型別:number
    const [firstChar, size] = firstTest("abc");

    常數斷言元組

    在上述的明確型別中,註記輸入的元組型別會很麻煩。

    TypeScript 提供一個 as const 運算符號作為替代方案,稱為常數斷言(const 斷言),原文為 const assertion,它可以放在資料之後。

    const 斷言:告訴 TypeScript 在推斷其型別時,可能使用字面唯讀的形式分析資料。如果將運算符號放在一個陣列之後,則表示該陣列應被視為一個元組,例:

    // 型別:(string | number)[]
    const unionArray = [1157, "abc"];
    
    // 型別:readonly [123, "def"]
    const readonlyTuple = [123, "def"] as const;

    留意 as const 斷言,不僅僅將大小靈活的陣列切換到固定大小的元組:它們還向 TypeScript 表明該元組是唯讀的,不能期望在其他地方做資料的修改。例:

    // 允許修改,因為具有傳統的明確元組型別
    const pairMutable: [number, string] = [1157, "ab"];
    pairMutable[0] = 1247; // 正確
    
    const pairConst = [1157, "cd"] as const;
    // 以下錯誤:Cannot assign to '0' because it is a read-only property.
    pairConst[0] = 11;

    另外,函式回傳的唯讀的 [string, number],但外部使用的程式碼只關心此回傳元組中的資料:

    // 回傳型別:readonly [string, number]
    function firstCharAsConst(input: string){
     return [input[0], input.length] as const;
    }
    
    // firstChar 型別:string
    // size 型別:number
    const [firstChar, size] = firstCharAsConst("abc");

  • [TypeScript] 函式

    函式參數

    以下函式:

    function sing(song){
      console.log(`Singing: ${song}!`);
    }

    song 參數因為沒有明確宣告型別資訊,所以 TypeScript 會認為它是 any 型別:表示參數的型別可以是任何型別。

    若要告知 song 為 string 型別的話,可用以下寫法:

    function sing(song: string){
      console.log(`Singing: ${song}!`);
    }

    必要參數

    TypeScript 在預設上,是假定所有參數都是必要的。如果使用錯誤數量的參數呼叫函式,TypeScript 將以型別錯誤的形式提出訊息。

    例:

    function singTwo(first: string, second: string) {
      console.log(`${first} / ${second}`);
    }
    
    // 錯誤:應有 2 個引數,但只得到 1 個。
    singTwo("Hi");
    
    // 正確
    singTwo("Hi", "Hello");
    
    // 錯誤:應有 2 個引數,但卻得到 3 個。
    singTwo("Hi", "Hello", "Wow");

    註:參數(Parameter) 是指函式宣告所期望接收的參數。引數(Argument)是指函式在呼叫時,提供給參數的資料,例如上面程式碼中,first 和 second 是參數,而 “Hi”、”Hello” 之類的是引數。

    選項參數

    在 JavaScript 中,如果未提供函式參數,則函式內部的參數值,預設就是 undefined。

    然而,在 TypeScript 中,可透過加上問號,來讓參數變成是可選擇的。

    例如以下程式,singer 參數被標記為可選擇的,它的型別是 string | undefined

    function announceSong(song: string, singer?: string){
      console.log(`Song: ${song}`);
    
      if(singer){ // 將 singer 窄化為 string
        console.log(`Singer: ${singer}`);
      }
    }
    
    announceSong("Hi"); // 允許
    announceSong("Hi", undefined); // 允許
    announceSong("Hi", "Sia"); // 允許

    如果是改成用以下的寫法,那麼 singer 參數就是一定要提供:

    function announceSongBy(song: string, singer: string | undefined){
      console.log(`Song: ${song}`);
    
      if(singer){ // 將 singer 窄化為 string
        console.log(`Singer: ${singer}`);
      }
    }
    
    announceSongBy("Hi"); // 錯誤:應有2個引數,但只有1個。
    announceSongBy("Hi", undefined); // 允許
    announceSongBy("Hi", "Sia"); // 允許

    註:函式當中的選擇性參數,必須擺在最後一個位置。倘若放在必要參數之前,那就會報錯。例:

    // 以下參數會報錯,必要參數不得放在選擇性參數之後。
    function announceSinger(singer?: string, song: string){
    }

    參數的預設值

    可以使用等號來指派參數的預設值。

    函式參數的預設值,若沒有給型別的話,TypeScript 將根據預設值推斷參數的型別:

    // rating 參數的型別會推斷為 number | undefined
    function rateSong(song: string, rating = 0){
        console.log(`${song} 獲得了 ${rating} 顆星!`);
    }
    
    rateSong("Photograph"); // 允許,會印出「Photograph 獲得了 0 顆星!」
    rateSong("hi1", 5); // 允許,會印出「hi1 獲得了 5 顆星!」
    rateSong("hi2", undefined); // 允許,會印出「hi2 獲得了 0 顆星!」
    
    rateSong("hi3", "100"); // 報錯,型別 string 的引數不可指派給型別 number 的參數。

    剩餘參數

    TypeScript 允許宣告剩餘(Rest)的型別,需要在結尾處增加 [] 語法,用以表示它是陣列的參數

    下例中,songs 為函式 singAllTheSongs 剩餘參數,並且是 0 個或多個 string 型別的陣列參數:

    function singAllTheSongs(singer: string, ...songs: string[]){
        for(const song of songs){
            console.log(`${singer} 唱 ${song}。`);
        }
    }
    
    singAllTheSongs("Alice"); // 允許
    singAllTheSongs("Mary", "a", "b"); // 允許
    
    singAllTheSongs("Mary", 100); // 報錯,型別 number 的引數不可指派給型別 string 的參數。

    回傳型別

    TypeScript 會自動依據函式的回傳值,來自動推斷該函式的回傳型別,

    例 1:

    // 型別:(songs: string[]) => number
    function singSongs(songs: string[]){
      for(const song of songs){
        console.log(`${song}`);
      }
      return songs.length;
    }

    例 2:

    // 型別:(songs: string[], index: number) => string | undefined
    function getSongAt(songs: string[], index: number){
      return index < songs.length ? songs[index] : undefined;
    }

    明確的回傳型別

    與變數一樣,通常不建議以明確宣告方式,在函式的回傳型別中使用型別註記。

    一般函式(下方的藍色文字即明確宣告該函式的回傳型別):

    function abc(): number{
      return 1;
    }

    如果是箭頭函式(下方的藍色文字即明確宣告該函式的回傳型別):

    const abc = (): number => {
      return 1;
    };

    函式型別

    函式型別語法看起來類似於箭頭函式,但操控的是型別而非主體。

    例1:

    // 描述一個沒有參數,並且回傳 string 數值的函式
    let nothingInGiveString: () => string;

    例2:

    // 描述一個帶有 string[] 參數、一個可選用的 count 參數,和一個回傳 number 的函式
    let inputAndOutput: (songs: string[], count?: number) => number;

    另外,函式型別也常用於描述回呼(callback)參數,例3:

    const songs = ["a", "b", "c"];
    
    function runOnSongs(getSongAt: (index: number) => string) {
      for(let i = 0; i < songs.length; i++){
        console.log(getSongAt(i));
      }
    }
    
    function getSongAt(index: number){
      return "" + songs[index] + "";
    }
    
    runOnSongs(getSongAt); // 允許
    
    function logSong(song: string){
      return "" + song + "";
    }
    
    runOnSongs(logSong); // 報錯:型別 (song: string) => string 引數,不可指派給型別 (index: number) => string 參數。參數 song 和 index 的型別不相容,型別 string 不可指派給型別 number。

    函式型別使用小括號

    在聯集型別中,小括號可用於標示哪一部份,是函式回傳的聯集型別:

    // 一個回傳型別為 string | undefined 聯集的函式
    let returnsStringOrUndefined: () => string | undefined;
    
    // 可能為 undefined 或回傳型別為 string 的函式
    let maybeReturnsString: (() => string) | undefined;

    參數型別推斷

    TypeScript 可以推斷函式中的參數型別,以及提供給相關位置的宣告型別。

    例如以下:

    let ginger: (song: string) => string;
    
    // 以下的 song 型別推斷為 string
    singer = function(song){
      return "Singing: " + song.toUpperCase();
    }

    再例如以下:

    const songs = ["a", "b", "c"];
    
    // song 推斷為 string
    // index 推斷為 number
    songs.forEach((song, index) => {
      console.log(song + " is at index " + index);
    });

    函式型別別名

    例:

    // 這個 StringToNumber 是一個函式型別別名
    type StringToNumber = (input: string) => number;
    
    let stringToNumber: StringToNumber;
    
    stringToNumber = (input) => input.length; // 正確
    
    let stringToNumber2: StringToNumber;
    stringToNumber2 = (input) => input.toUpperCase(); // 會報錯:型別 string 不可指派給型別 number

    函式型別別名也可以用在函式的參數。例:

    // 這個 NumberToString 是一個函式型別別名
    type NumberToString = (input: number) => string;
    
    function usesNumberToString(numberToString: NumberToString) {
      console.log("The string is: " + numberToString(1234));
    }
    
    usesNumberToString((input) => {
      return input + "! Hooray!";
    }); // 正確
    
    // 以下錯誤:型別 number 不可指派給型別 string
    usesNumberToString((input) => input * 2);

    其它回傳型別

    回傳 void 型別

    有些函式,並沒有任何回傳值。可能是沒有任何 return 語句,或者也有可能是 return 語句上沒有回傳資料。TypeScript 允許使用 void 關鍵字,來表達這種情況。

    回傳型別為 void 的函式,可能不會回傳值。因此下例的 logSong 函式,宣告為回傳 void,因此不允許回傳資料:

    function logSong(song: string | undefined): void {
      if(!song){
        return; // 正確
      }
    
      console.log("" + song + "");
    
      return true; // 錯誤:型別 boolean 不可指派給型別 void
    }

    void 可用在函式型別宣告中的回傳型別。在函式型別宣告中使用時,void 表示函式的任何回傳值都會被忽略。例如以下範例:

    // songLogger 變數會是一個函式,它接收一個字串,且不回傳資料
    let songLogger: (song: string) => void;
    
    songLogger = (song) => {
      console.log("" + song + "");
    };
    
    songLogger("Hello"); // 正確

    注意:雖然 JavaScript 函式在沒有實際回傳資料的情況下,預設是回傳 undefined ,但 void 與 undefined 是不一樣的。void 表示函式的回傳型別將被忽略,而 undefined 是回傳的字面數值。嘗試將型別為 void 的值指派給型別包含 undefined 的值,會造成型別錯誤

    // returnsVoid 的回傳型別會推斷為 void
    function returnsVoid(){
      return;
    }
    let lazyValue: string | undefined;
    
    // 以下錯誤:型別 void 不可指派給型別 string | undefined
    lazyValue = returnsVoid();

    最後留意:void 型別在 JavaScript 中並沒有,它是 TypeScript 中的關鍵字,用於宣告函式的回傳型別。需記得,void 並不是表示函式本身可以回傳的數值,而是回傳值並不打算使用。

    回傳 never 型別

    有些函式不僅不回傳資料,反而是刻意不回傳。永遠不回傳的函式,是那些總是拋出錯誤或執行無限循環的函式。

    如果函式刻意永遠不回傳資料,那就需明確加入 never 型別註記,表示執行該函式後的任何程式碼都不會執行

    例:

    function fail(message: string): never {
      throw new Error("錯誤:" + message);
    }
    
    function workWithUnsafeParam(param: unknown) {
      if(typeof param !== "string"){
        fail("參數應是 string。");
      }
    
      // 這裡的 param 已知是 string 型別
      param.toUpperCase(); // 正確
    }

    註:never 不等於 void。void 用於不回傳任何資料的函式;never 用於表示永遠不會回傳的函式。

    函式重載

    某些 JavaScript 函式可以使用完全不同的參數集合來呼叫,這些參數集合會以可選擇的參數或剩餘參數來表示。

    這些函式在 TypeScript 語法,可稱為 重載特徵/重載簽章(overload signatures) 來描述:在一個最終實做特徵(implementation signature)的函式主體之前,先宣告多個不同版本的函式名稱、參數和回傳型別。

    下例:前兩行是重載特徵,第三行是實作特徵:

    function createDate(timestamp: number): Date;
    function createDate(month: number, day: number, year: number): Date;
    function createDate(monthOrTimestamp: number, day?: number, year?: number) {
      return day === undefined || year === undefined ? new Date(monthOrTimestamp) : new Date(year, monthOrTimestamp, day);
    }
    
    createDate(554356800); // 正確
    createDate(7, 28, 1987); // 正確
    
    // 報錯:沒有任何重載預期 2 個引數,但有重載預期1或3個引數
    createDate(4, 1);

    註:函式重載通常用作複雜,用來表示難以敘述的函式型別之最後手段。一般來說,最好還是保持函式的簡單,並且盡可能避免使用函式重載。

  • [TypeScript] 物件

    物件型別

    當使用 {…} 語法來建立物件字面型態時,TypeScript 將根據其屬性將其視為新的物件型別。

    例如以下程式:

    // 物件字面
    const poet = {
      born: 1935,
      name: "Hi"
    };

    TypeScript 能分析出 poet 變數是具有兩個屬性的物件型別:born 是 number 型別、name 為 string 型別。

    若嘗試存取任何其它屬性資料,會導致該名稱不在存的型別錯誤,例:

    poet.end; // 報錯:型別 '{born: number; name: string;}' 沒有屬性 end。

    物件型別是 TypeScript 如何解析 JavaScript 程式碼的核心概念。除了 null 和 undefined 之外的每個資料都有其支援型別,因此 TypeScript 必須理解每個變數的物件型別才能進行型別檢查。

    宣告物件型別

    明確宣告物件的型別,是個比較好的方式。使用物件型別類似於物件字面,但會使用型別而不是物件字面數值的語法來做描述。例:

    // 明確的宣告物件型別
    let poetLater: {
      born: number;
      name: string;
    };
    
    // 正確
    poetLater = {
      born: 1935,
      name: "Hi"
    };
    
    // 錯誤:型別 string 不可指派給型別 '{ born: number; name: string; }'
    poetLater = "Sappho";

    物件型別別名

    反覆寫出像 { born: number; name: string; } 會很快令人厭煩,所以可使用型別別名,來為每個型別形態指定一個名稱。例:

    // 物件型別別名
    type Poet = {
      born: number;
      name: string;
    };
    
    let poetLater: Poet;
    
    // 正確
    poetLater = {
      born: 1935,
      name: "Hi"
    };
    
    // 錯誤:型別 string 不可指派給型別 '{ born: number; name: string; }'
    poetLater = "Hi";

    大多數的 TypeScript 專案更偏好使用 interface 關鍵字來描述物件型別。物件型別別名和 interface 幾乎相同。

    結構型別

    TypeScript 是結構化型別(structuraly typed);JavaScript 是鴨子型別(duck typing)。

    • 結構型別適用於在靜態型別檢查時。
    • 鴨子型別通常是在執行時使用,在執行之前沒有檢查物件型別。

    針對結構化型別:意味著只要滿足型別的資料,該資料都可以指派給變數。例:

    type WithFirstName = {
      firstName: string;
    };
    
    type WithLastName = {
      lastName: string;
    };
    
    const hasBoth = {
      firstName: "Lucille",
      lastName: "Clifton"
    };
    
    // 允許,因為 withFirstName 的型別是 WithFirstName,只要 hasBoth 這個資料至少要有 firstName 即可。
    let withFirstName: WithFirstName = hasBoth;
    
    // 允許,因為 withLastName 的型別是 WithLastName,只要 hasBoth 這個資料至少要有 lastName 即可。
    let withLastName: WithLastName = hasBoth;

    型別檢查

    再看一個例子:

    type FirstAndLastNames = {
      first: string;
      last: string;
    };
    
    // 允許
    const hasBoth: FirstAndLastNames = {
      first: "abc",
      last: "def"
    };
    
    // 報錯:型別 '{first: string; }' 缺少屬性 last。
    const hasOnlyOne: FirstAndLastNames = {
      first: "hhh"
    };

    再看一個例子:

    type TimeRange = {
      start: Date;
    };
    
    const hasStartString: TimeRange = {
      // 報錯:型別 string 不可指派給型別 Date。
      start: "1999-01-01"
    };

    多餘屬性的檢查

    使用物件型別宣告變數,並且其初始值的欄位多於其型別描述的欄位,TypeScript 將會報錯。例:

    type Poet = {
      born: number;
      name: string;
    };
    
    // 允許
    const poetMatch: Poet = {
      born: 1928,
      name: "Hi"
    };
    
    // 報錯:型別 '{ activity: string; born: number; name: string; }' 不可指派給型別 Poet。
    const extraProperty: Poet = {
      activity: "Hi",
      born: 1935,
      name: "Hi2"
    };

    注意:這個檢查只會觸發在宣告建立物件型別的位置文字上。直接提供現有的物件,會饒過多餘的屬性檢查。

    以下這個例子,extraPropertyButOk 變數不會觸發前一個例子當中的錯誤,因為它的初始值恰好在結構比對 Poet 的位置上:

    type Poet = {
      born: number;
      name: string;
    };
    
    const existingObject = {
      activity: "Hi",
      born: 1935,
      name: "Hi22"
    };
    
    // 允許
    const extraPropertyButOk: Poet = existingObject;

    多餘屬性的檢查,將在任何位置建立新物件時觸發,在該位置期望與物件型別一致。

    巢狀物件型別

    跟之前的概念是一樣的。例:

    type Poem = {
      author: {
        firstName: string;
        lastName: string;
      };
      name: string;
    };
    
    // 允許
    const poemMatch: Poem = {
      author: {
        firstName: "first",
        lastName: "last"
      },
      name: "lady"
    };
    
    const poemMismatch: Poem = {
      // 報錯:型別 '{ name: string; }' 不可指派給型別 '{ firstName: string; lastName: string; }'
      author: {
        name: "Hi"
      },
      name: "lady"
    };

    也可以改成這樣寫:

    type Author = {
      firstName: string;
      lastName: string;
    };
    
    type Poem = {
      author: Author;
      name: string;
    };
    
    const poemMismatch: Poem = {
      // 報錯:型別 '{ name: string; }' 不可指派給型別 Author。
      author: {
        name: "Hi"
      },
      name: "lady"
    };

    像這樣將巢狀物件型別移到其它的型別名稱當中,是好的方法,可提高程式碼和錯誤訊息的可讀性。

    可選擇的屬性

    物件型別屬性,在某些物件中不見得都是必要的。所以我們可以在冒號之前加入問號(?),來表示它是一個可選擇的屬性。例:

    type Book = {
      author?: string; // 表示 author 屬性是非必須的
      pages: number;
    };
    
    // 允許
    const ok: Book = {
      author: "Hi",
      pages: 80
    };
    
    // 報錯:型別 '{ author: string; }' 缺少屬性 pages,型別 Book 必須有該屬性。
    const missing: Book = {
      author: "Hi"
    };

    物件型別的聯集

    推斷聯集的物件型別

    如果一個變數可能是一個或多個物件型別之一作為作始值,TypeScript 將推斷它的型別,會是物件型別的聯集。

    例如以下這段程式:

    const poem = Math.random() > 0.5 ? { name: "Hi", pages: 7 } : { name: "hi2", rhymes: true };

    判斷出來的型別如下圖紅框處:

    明確的聯集物件型別

    上個例子的推斷可能不是想要的。我們可以用另一種更明確也較好的方式:可以透過明確表示我們自己所使用的物件型別聯集來描述的物件型別。

    type PoemWithPages = {
      name: string;
      pages: number;
    };
    
    type PoemWithRhymes = {
      name: string;
      rhymes: boolean;
    };
    
    type Poem = PoemWithPages | PoemWithRhymes;
    
    const poem: Poem = Math.random() > 0.5 ? { name: "Hi", pages: 7 } : { name: "Hi2", rhymes: true };
    
    poem.name; // 允許
    
    // 會報錯。型別 Poem 沒有屬性 pages。型別 PoemWithRhymes 沒有屬性 pages。
    poem.pages;
    
    // 會報錯。型別 Poem 沒有屬性 rhymes。型別 PoemWithPages 沒有屬性 rhymes。
    poem.rhymes;

    接下來,必須進一步型別窄化才得以存取。

    窄化物件型別

    延續前一個例子,如果寫這樣:

    type PoemWithPages = {
      name: string;
      pages: number;
    };
    
    type PoemWithRhymes = {
      name: string;
      rhymes: boolean;
    };
    
    type Poem = PoemWithPages | PoemWithRhymes;
    
    if("pages" in poem){
      poem.pages; // 這是允許的,poem 被窄化成 PoemWithPages。
    } else {
      poem.rhymes; // 這是允許的,poem 被窄化成 PoemWithRhymes。
    }

    可辨識的型別

    JavaScript 和 TypeScript 中,聯集型別的另一種常見形式是在物件上,會用一個屬性來指示物件的型態。這種型別形態稱為可辨識的聯集(discriminant union),其值表示物件型別的屬性是可被分辨的。例:

    type PoemWithPages = {
      name: string;
      pages: number;
      type: 'pages';
    };
    
    type PoemWithRhymes = {
      name: string;
      rhymes: boolean;
      type: 'rhymes';
    };
    
    type Poem = PoemWithPages | PoemWithRhymes;
    
    const poem: Poem = Math.random() > 0.5 ? { name: "Hi", pages: 7, type: "pages"} : { name: "Hi2", rhymes: true, type: "rhymes" };
    
    if(poem.type === "pages"){
      console.log(`可使用 ${poem.pages}`);
    } else {
      console.log(`可使用 ${poem.rhymes}`);
    }
    
    poem.type; // 型別:'pages' | 'rhymes'
    
    poem.pages; // 報錯:型別 Poem 沒有屬性 pages。型別 PoemWithRhymes 沒有屬性 pages。

    交集型別

    TypeScript 允許同時表示多個型別:使用 & 符號來表示交集型別。

    例如以下例子:

    type Artwork = {
      genre: string;
      name: string;
    };
    
    type Writing = {
      pages: number;
      name: string;
    };
    
    type WrittenArt = Artwork & Writing;
    // 等同於:
    // {
    //   genre: string;
    //   name: string;
    //   pages: number;
    // }

    交集型別可以和聯集型別一起使用,這也有助於在一種型別中描述可辨識的聯集。例:

    type ShortPoem = { author: string; } & ( | { kigo: string; type: "haiku"; } | { meter: number; type: "villanelle"; });
    
    // 允許
    const morningGlory: ShortPoem = {
      author: "Hi",
      kigo: "abc",
      type: "haiku"
    };
    
    // 這會報錯
    const oneArt: ShortPoem = {
      author: "Hi",
      type: "villanelle"
    };

    never 型別

    交集型別容易被誤用,並建立出一個不可能的型別。例如原始資料型別不能作為交集型別組成的一部份,因為一個變數不可能是多個原始資料型別。

    例如以下例子,將導致 never 型別呈現出來:

    type NotPossible = number & string;
    // 型別 never

    這個 never 關鍵字的型別是底限型別(bottom type)空型別。never 型別是一種不可能的資料,而且無法轉換的型別。沒有型別能代替 never 型別。

    例:

    type NotPossible = number & string;
    
    // 報錯:型別 number 不可指派給型別 never。
    let notNumber: NotPossible = 0;
    
    // 報錯:型別 string 不可指派給型別 never。
    let notString: never = "";

    基本上,大多數專案不太會使用到 never 型別。

  • [TypeScript] 聯集與字面

    TypeScript 進行型別推斷的兩個關鍵概念:

    • 聯集(Unions):將資料的允許型別擴充為兩種或多種可能的型別。
    • 窄化(Narrowing):將資料的允許型別盡量減少。

    聯集型別

    例如以下程式:

    let mathematician = Math.random() > 0.5 ? undefined : "HI";

    mathematician 會是什麼型別?會是 string | undefined 聯集(Union)型別。

    TypeScript 使用 | (管道) 運算符號,來表示資料可能的型別。

    宣告聯集型別

    以下範例,thinker 的初始資料為 null,但它的聯集型別為 string | null,所以後續也可以將字串資料,指派給 thinker 變數:

    let thinker: string | null = null;
    thinker = "Hi"; // 這是允許的

    聯集屬性

    例如以下程式:

    let physicist = Math.random() > 0.5 ? "Hi" : 84; // 可得知 physicist 的聯集型別為 number | string。
    
    physicist.toString(); // 這是允許的,因為 number 及 string 型別都有 toString() 函式。
    
    physicist.toUpperCase(); // 這會報錯,因為 number 型別沒有 toUpperCase() 函式。
    
    physicist.toFixed(); // 這會報錯,因為 string 型別沒有 toFixed() 函式。

    限制對所有聯集型別存取不存在的屬性,是一種安全措施。

    要使用僅存在於特定型別上的屬性時,我們需要向 TypeScript 提出更具體說明,這稱為窄化(narrowing)的過程。

    窄化

    窄化是指程式碼中,推斷出資料的型別,以及它的定義、宣告,使之更具體化。一旦 TypeScript 知道一個資料的型別比先前知道的更窄小,將允許我們將變數視為更具體的型別。可用於縮小型別的邏輯檢查,稱之為型別防護(type guard)

    指派的窄化

    如果直接為變數指派資料,TypeScript 會將變數的型別窄化到該資料的型別。例:

    let admiral: number | string; // 聯集型別
    admiral = "Hi";               // 窄化為 string 型別
    admiral.toUpperCase();        // 是可以的
    
    admiral.toFixed(); // 會報錯,因為型別 string 沒有 toFixed() 函式。

    或者也可能是以下,宣告型為為 number | string,然後馬上指派資料,也會發生窄化的效果:

    let inventor: number | string = "Hi"; // 窄化為 string
    inventor.toUpperCase(); // 正確:型別為 string
    inventor.toFixed();     // 報錯,型別 string 沒有 toFixed() 函式。

    條件檢查的窄化

    例如以下範例:

    let scientist = Math.random() > 0.5 ? "Hi" : 51; // number | string
    
    if(scientist === "Hi"){
      scientist.toUpperCase(); // 是可以的,因為前面 if 條件判斷的關係,所以 scientist 型別窄化為 string。
    }
    
    scientist.toUpperCase(); // 報錯,因為這裡的 scientist 型別為 number | string,在 number 型別中,沒有 toUpperCase() 函式。

    使用條件邏輯窄化 TypeScript 的型別檢查,TypeScript 迫使我們安全地使用程式碼。

    型別檢查的窄化

    可以使用 typeof 關鍵字來窄化變數型別,例如:

    let researcher = Math.random() > 0.5 ? "Hi" : 51; // number | string
    
    if(typeof researcher === "string"){
      researcher.toUpperCase(); // 是可以的,型別為 string
    }
    
    // 邏輯的否定 ! 以及 else 語法也可以一併使用
    if(!(typeof researcher === "string")){
      researcher.toFixed(); // 是可以的,型別為 number
    }else{
      researcher.toUpperCase(); // 是可以的,型別為 string
    }
    
    // 三元運算子範例
    typeof researcher === "string" ? researcher.toUpperCase() : researcher.toFixed(); // 這也是可以的

    字面型別(literal types)

    前面已瞭解了聯集型別和窄化的處理,接下來可透過字面型別(literal types):用以表達更具體的原始型別版本

    例如以下這個範例:

    const philosopher = "Hi";

    乍看之下,沒錯,philosopher 的型別是 string。但也可以換另一種更具體的說法,可以說是 “Hi” 這個字面型別。

    也就是如果將一個變數宣告為 const,並直接給一個固定資料,TypeScript 就會將該變數推斷為字面資料,視其為一種型別。例如下圖:

    若是改用 let 來做變數的宣告,則會推斷為原始型別,如下例:

    聯集型別註記

    例如以下範例:

    let lifespan: number | "ongoing" | "uncertain"; // 聯集型別 number | "ongoing" | "uncertain"
    
    lifespan = 89;        // 是可以的
    lifespan = "ongoing"; // 是可以的
    
    lifespan = true;      // 這會報錯,因為 true 不可指派給型別 number | "ongoing" | "uncertain"

    字面指派性

    例如以下範例:

    let specificallyAda: "Ada"; // 被宣告為字面型別「Ada」
    
    specificallyAda = "Ada";    // 是可以的
    
    specificallyAda = "Byron";  // 會報錯,型別 "Byron" 不可指派給型別 "Ada"。

    嚴格的 null 檢查(strictNullChecks)

    有一些程式語言,沒有嚴格檢查空值(null)的情況,會出現下面這樣將 null 指派給 string 的程式碼:

    const firstName: string = null;

    這種情況是非常不好的。

    所以在 TypeScript 程式的設定檔當中,其中有一個選項是 strictNullChecks,該功能最好是要開啟(設定成 true),如果關閉的話,會讓 nullundefined 允許指派給變數,這非常不好。

    真值(truthy)的窄化

    JavaScript 中的所有值都是真值(truthy);有一些例外會被定義為假值(falsy),例:false、0、-0、0n、””、null、undefined 和 NaN。

    TypeScript 還可以透過真假值檢查來窄化變數的型別,例如以下程式:

    let geneticist = Math.random() > 0.5 ? "Hi" : undefined;
    
    if(geneticist){
      geneticist.toUpperCase(); // 這是允許的,因為窄化了真值
    }
    
    geneticist.toUpperCase(); // 這會報錯,因為 geneticist 可能是 undefined。

    執行真假值檢查在邏輯運算符也是可以運作的,例:

    geneticist && geneticist.toUpperCase(); // OK, string | undefined
    geneticist?.toUpperCase(); // OK, string | undefined
    

    沒有初始值的變數

    沒有初始值的變數宣告,在 JavaScript 中,預設為 undefined。

    在型別系統中提出了一個情況:如果將變數宣告為不包含 undefined 的型別,然後在指派資料之前嚐試使用的話,就會報出錯誤訊息

    let a: string;
    a?.length; // 報錯:變數 a 在指派之前使用
    
    a = "Hi";
    a.length; // OK

    但如果變數的型別包含 undefined 的話,那就不會報錯了,例:

    let a: string | undefined;
    a?.length; // OK
    
    a = "Hi";
    a.length; // OK

    型別別名

    以下程式為範例:

    let rawDataFirst: boolean | number | string | null | undefined;
    let rawDataSecond: boolean | number | string | null | undefined;
    let rawDataThird: boolean | number | string | null | undefined;

    TypeScript 允許型別別名(type aliases),變為更簡單的名稱。

    型別別名以關鍵字 type、型別名稱、等號作為開頭,習慣上,型別別名以大寫駝峰式來寫,例:

    type MyName = …;

    上述程式,可改寫成:

    type RawData = boolean | number | string | null | undefined;
    
    let rawDataFirst: RawData;
    let rawDataSecond: RawData;
    let rawDataThird: RawData;

    變得更易讀了。

    JavaScript 沒有型別別名

    也就是上述程式碼,若編譯為 JavaScript 時,會是如下:

    let rawDataFirst;
    let rawDataSecond;
    let rawDataThird;

    因為在 JavaScript 程式語法當中,是沒有型別別名這東西的,所以型別別名只會存在於 TypeScript 程式語法當中。

    型別別名的組合

    型別別名可以參考其它型別別名,例:

    type Id = number | string;
    type IdMaybe = Id | undefined | null; // 相當於 number | string | undefined | null

    型別別名可以不用依照使用順序來宣告,所以也可以寫成如下:

    type IdMaybe = Id | undefined | null;
    type Id = number | string;