跳到主要內容
黯羽輕揚每天積累一點點

類型別名與字面量類型_TypeScript 筆記 10

免費2019-03-10#TypeScript#可辨识联合#narrow this type#TypeScript Type Aliases#TypeScript Literal Types#type assertions and type guards

TS 裡代數數據類型的由來

一、類型別名

type PersonName = string;
type PhoneNumber = string;
type PhoneBookItem = [PersonName, PhoneNumber];
type PhoneBook = PhoneBookItem[];

let book: PhoneBook = [
  ['Lily', '1234'],
  ['Jean', '1234']
];

type 關鍵字能為現有類型創建一個別名,從而增強其可讀性

接口與類型別名

類型形式上與接口有些類似,都支持類型參數,且可以引用自身,例如:

type Tree<T> = {
    value: T;
    left: Tree<T>;
    right: Tree<T>;
}

interface ITree<T> { 
  value: T;
  left: ITree<T>;
  right: ITree<T>;
}

但存在一些本質差異:

  • 類型別名並不會創建新類型,而接口會定義一個新類型

  • 允許給任意類型起別名,但無法給任意類型定義與之等價的接口(比如基礎類型)

  • 無法繼承或實現類型別名(也不能擴展或實現其它類型),但接口可以

  • 類型別名能將多個類型組合成一個具名類型,而接口無法描述這種組合(交叉、聯合等)

// 類型組合,接口無法表達這種類型
type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
  name: string;
}
function findSomeone(people: LinkedList<Person>, name: string) {
  people.name;
  people.next.name;
  people.next.next.name;
  people.next.next.next.name;
}

應用場景上,二者區別如下:

  • 接口:OOP 場景(因為能被繼承和實現,維持著類型層級關係)

  • 類型別名:追求可讀性的場景、接口無法描述的場景(基礎類型、交叉類型、聯合類型等)

二、字面量類型

存在兩種字面量類型:字符串字面量類型與數值字面量類型

字符串

字符串字面量也具有類型含義,例如:

let x: 'string';
// 錯誤 Type '"a"' is not assignable to type '"string"'.
x = 'a';
// 正確
x = 'string';

可以用來模擬枚舉的效果:

type Easing = 'ease-in' | 'ease-out' | 'ease-in-out';
class UIElement {
  animate(dx: number, dy: number, easing: Easing) {
    if (easing === 'ease-in') {}
    else if (easing === 'ease-out') {}
    else {
      // 自動縮窄到"ease-in-out"類型
    }
  }
}

// 錯誤 Argument of type '"linear"' is not assignable to parameter of type 'Easing'.
new UIElement().animate(0, 0, 'linear');

不同的字符串字面量屬於不同的具體類型,因此,(如果必要的話)可以這樣重載:

function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
function createElement(tagName: string): Element {
  return document.createElement(tagName);
}

數值

數值字面量同樣具有類型含義:

// 返回骰子的 6 個點數
function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 {
  // ...
}

看起來只是個匿名 數值枚舉,似乎沒什麼存在必要

存在意義

實際上,字面量類型的意義在於編譯時能夠結合類型信息「推理」,例如:

function foo(x: number) {
  // 錯誤 This condition will always return 'true' since the types '1' and '2' have no overlap.
  if (x !== 1 || x !== 2) { }
}

function bar(x: string) {
  // 錯誤 This condition will always return 'false' since the types '"1"' and '"2"' have no overlap.
  if (x === '1' && x === '2') { 
    //...
  }
}

這種類型完整性補充讓 TypeScript 能夠更細緻地「理解」(靜態分析)代碼含義,進而發現一些不那麼直接的潛在問題

Nevertheless, by pairing a type with it's unique inhabitant, singleton types bridge the gap between types and values.

三、枚舉與字面量類型

我們知道有一種特殊的枚舉叫 聯合枚舉,其成員也具有類型含義,例如:

// 聯合枚舉
enum E {
  Foo,
  Bar,
}

// 枚舉的類型含義
function f(x: E) {
  // 錯誤 This condition will always return 'true' since the types 'E.Foo' and 'E.Bar' have no overlap.
  if (x !== E.Foo || x !== E.Bar) {
    //...
  }
}

這與字面量類型中的例子非常相似:

function f(x: 'Foo' | 'Bar') {
  // 錯誤 This condition will always return 'true' since the types '"Foo"' and '"Bar"' have no overlap.
  if (x !== 'Foo' || x !== 'Bar') {
    //...
  }
}

P.S. 類比起見,這裡用字符串字面量聯合類型('Foo' | 'Bar')模擬枚舉 E,實際上枚舉 E 等價於數值字面量聯合類型(0 | 1),具體見 二。數值枚舉

從類型角度來看,聯合枚舉就是由數值/字符串字面量構成的枚舉,因此其成員也具有類型含義。名稱上也表達了這種聯繫:聯合枚舉,即數值/字符串聯合

P.S. 枚舉成員類型與數值/字符串字面量類型也叫單例類型(singleton types)

Singleton types, types which have a unique inhabitant.

也就是說,一個單例類型下只有一個值,例如字符串字面量類型 'Foo' 只能取值字符串 'Foo'

四、可區分聯合

結合單例類型、聯合類型、類型保護和類型別名可以建立一種模式,稱為可區分聯合(discriminated unions)

P.S. 可區分聯合也叫標籤聯合(tagged unions)或 [代數數據類型(algebraic data types)](/articles/類型-haskell 筆記 3/#articleHeader6),即可運算、可進行邏輯推理的類型

具體地,可區分聯合一般包括 3 部分:

  • 一些具有公共單例類型屬性的類型——公共單例屬性即可區分的特徵(或者叫標籤)
  • 一個指向這些類型構成的聯合的類型別名——即聯合
  • 針對公共屬性的類型保護

通過區分公共單例屬性的類型來縮窄父類型,例如:

// 1. 一些具有公共單例屬性(kind)的類型
interface Square {
    kind: "square";
    size: number;
}
interface Circle {
    kind: "circle";
    radius: number;
}
// 2. 定義聯合類型,並起個別名
type Shape = Square | Circle;
// 3. 具體使用(類型保護)
function area(s: Shape) {
  switch (s.kind) {
    // 自動縮窄到 Square
    case "square": return s.size * s.size;
    // 自動縮窄到 Circle
    case "circle": return Math.PI * s.radius ** 2;
  }
}

算是對 [instanceof 類型保護](/articles/組合類型與類型保護-typescript 筆記 9/#articleHeader6) 的一種補充,都用於檢測複雜類型的兼容關係,區別如下:

  • instanceof 類型保護:適用於有明確繼承關係的父子類型

  • 可區分聯合類型保護:適用於沒有明確繼承關係(運行時通過 instanceof 檢測不出繼承關係)的父子類型

完整性檢查

有些時候可能想要完整覆蓋聯合類型的所有組成類型,例如:

type Shape = Square | Circle;
function area(s: Shape) {
  switch (s.kind) {
    case "square": return s.size * s.size;
    // 潛在問題:漏掉了"circle"
  }
}

可以通過 never 類型來實現這種保障(Never 類型為數不多的應用場景之一):

function assertNever(x: never) {
  throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
  switch (s.kind) {
    case "square": return s.size * s.size;
    // case "circle": return s.radius * s.radius;
    // 錯誤 Argument of type 'Circle' is not assignable to parameter of type 'never'.
    default: return assertNever(s);
  }
}

如果沒有完整覆蓋,就會走到 default 分支把 s: Shape 傳遞給 x: never 引發類型錯誤(完整覆蓋了的話,default 就是不可達分支,不會引發 never 錯誤)。能夠滿足完整性覆蓋要求,但需要額外定義一個 assertNever 函數

P.S. 關於 Never 類型的更多信息,見 [基本類型_TypeScript 筆記 2](/articles/基本類型-typescript 筆記 2/#articleHeader3)

此外,還有一種不那麼準確,但也有助於檢查完整性的方法:開啓 --strictNullChecks 選項,並標明函數返回值。利用默認返回 undefined 來保證完整性,例如:

// 錯誤 Function lacks ending return statement and return type does not include 'undefined'.
function area(s: Shape): number {
  switch (s.kind) {
    case "square": return s.size * s.size;
  }
}

實質上是非空返回值檢測,不像 assertNever 是精確到 switch 粒度的,相對脆弱(有默認返回值,或有多個 switch 都會破壞完整性檢查)

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論