TypeScript 之 Narrowing

冴羽發表於2021-11-12

前言

TypeScript 的官方文件早已更新,但我能找到的中文文件都還停留在比較老的版本。所以對其中新增及修改較多的一些章節進行了個人的翻譯整理。

本篇整理自 https://www.typescriptlang.org/docs/handbook/2/narrowing.html

本文並不完全遵循原文翻譯,對部分內容自己也做了解釋補充。

Narrowing

試想我們有這樣一個函式,函式名為 padLeft:

function padLeft(padding: number | string, input: string): string {
  throw new Error("Not implemented yet!");
}

該函式實現的功能是:

如果引數 padding 是一個數字,我們就在 input 前面新增同等數量的空格,而如果 padding 是一個字串,我們就直接新增到 input 前面。

讓我們實現一下這個邏輯:

function padLeft(padding: number | string, input: string) {
  return new Array(padding + 1).join(" ") + input;
    // Operator '+' cannot be applied to types 'string | number' and 'number'.
}

如果這樣寫的話,編輯器裡 padding + 1 這個地方就會標紅,顯示一個錯誤。

這是 TypeScript 在警告我們,如果把一個 number 型別 (即例子裡的數字 1 )和一個 number | string 型別相加,也許並不會達到我們想要的結果。換句話說,我們應該先檢查下 padding 是否是一個 number,或者處理下當 paddingstring 的情況,那我們可以這樣做:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return new Array(padding + 1).join(" ") + input;
  }
  return padding + input;
}

這個程式碼看上去也許沒有什麼有意思的地方,但實際上,TypeScript 在背後做了很多東西。

TypeScript 要學著分析這些使用了靜態型別的值在執行時的具體型別。目前 TypeScript 已經實現了比如 if/else 、三元運算子、迴圈、真值檢查等情況下的型別分析。

在我們的 if 語句中,TypeScript 會認為 typeof padding === number 是一種特殊形式的程式碼,我們稱之為型別保護 (type guard),TypeScript 會沿著執行時可能的路徑,分析值在給定的位置上最具體的型別。

TypeScript 的型別檢查器會考慮到這些型別保護和賦值語句,而這個將型別推導為更精確型別的過程,我們稱之為收窄 (narrowing)。 在編輯器中,我們可以觀察到型別的改變:

image.png

從上圖中可以看到在 if 語句中,和剩餘的 return 語句中,padding 的型別都推導為更精確的型別。

接下來,我們就介紹 narrowing 所涉及的各種內容。

typeof 型別保護(type guards)

JavaScript 本身就提供了 typeof 操作符,可以返回執行時一個值的基本型別資訊,會返回如下這些特定的字串:

  • "string"
  • "number"
  • "bigInt"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

typeof 操作符在很多 JavaScript 庫中都有著廣泛的應用,而 TypeScript 已經可以做到理解並在不同的分支中將型別收窄。

在 TypeScript 中,檢查 typeof 返回的值就是一種型別保護。TypeScript 知道 typeof 不同值的結果,它也能識別 JavaScript 中一些怪異的地方,就比如在上面的列表中,typeof 並沒有返回字串 null,看下面這個例子:

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    for (const s of strs) {
          // Object is possibly 'null'.
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  } else {
    // do nothing
  }
}

在這個 printAll 函式中,我們嘗試判斷 strs 是否是一個物件,原本的目的是判斷它是否是一個陣列型別,但是在 JavaScript 中,typeof null 也會返回 object。而這是 JavaScript 一個不幸的歷史事故。

熟練的使用者自然不會感到驚訝,但也並不是所有人都如此熟練。不過幸運的是,TypeScript 會讓我們知道 strs 被收窄為 strings[] | null ,而不僅僅是 string[]

真值收窄(Truthiness narrowing)

在 JavaScript 中,我們可以在條件語句中使用任何表示式,比如 &&||! 等,舉個例子,像 if 語句就不需要條件的結果總是 boolean 型別

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `There are ${numUsersOnline} online now!`;
  }
  return "Nobody's here. :(";
}

這是因為 JavaScript 會做隱式型別轉換,像 0NaN""0nnull undefined 這些值都會被轉為 false,其他的值則會被轉為 true

當然你也可以使用 Boolean 函式強制轉為 boolean 值,或者使用更加簡短的!!

// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

這種使用方式非常流行,尤其適用於防範 nullundefiend 這種值的時候。舉個例子,我們可以在 printAll 函式中這樣使用:

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

可以看到通過這種方式,成功的去除了錯誤。

但還是要注意,在基本型別上的真值檢查很容易導致錯誤,比如,如果我們這樣寫 printAll 函式:

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  DON'T DO THIS!
  //   KEEP READING
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

我們把原本函式體的內容包裹在一個 if (strs) 真值檢查裡,這裡有一個問題,就是我們無法正確處理空字串的情況。如果傳入的是空字串,真值檢查判斷為 false,就會進入錯誤的處理分支。

如果你不熟悉 JavaScript ,你應該注意這種情況。

另外一個通過真值檢查收窄型別的方式是通過!操作符。

function multiplyAll(
  values: number[] | undefined,
  factor: number
): number[] | undefined {
  if (!values) {
    return values;
    // (parameter) values: undefined
  } else {
    return values.map((x) => x * factor);
    // (parameter) values: number[]
  }
}

等值收窄(Equality narrowing)

Typescript 也會使用 switch 語句和等值檢查比如 == !== == != 去收窄型別。比如:

image.png

在這個例子中,我們判斷了 xy 是否完全相等,如果完全相等,那他們的型別肯定也完全相等。而 string 型別就是 xy 唯一可能的相同型別。所以在第一個分支裡,xy 就一定是 string 型別。

判斷具體的字面量值也能讓 TypeScript 正確的判斷型別。在上一節真值收窄中,我們寫下了一個沒有正確處理空字串情況的 printAll 函式,現在我們可以使用一個更具體的判斷來排除掉 null 的情況:

image.png

JavaScript 的寬鬆相等操作符如 ==!= 也可以正確的收窄。在 JavaScript 中,通過 == null 這種方式並不能準確的判斷出這個值就是 null,它也有可能是 undefined 。對 == undefined 也是一樣,不過利用這點,我們可以方便的判斷一個值既不是 null 也不是 undefined

image.png

in 操作符收窄

JavaScript 中有一個 in 操作符可以判斷一個物件是否有對應的屬性名。TypeScript 也可以通過這個收窄型別。

舉個例子,在 "value" in x 中,"value" 是一個字串字面量,而 x 是一個聯合型別:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim();
    // (parameter) animal: Fish
  }
 
  return animal.fly();
  // (parameter) animal: Bird
}

通過 "swim" in animal ,我們可以準確的進行型別收窄。

而如果有可選屬性,比如一個人類既可以 swim 也可以 fly (藉助裝備),也能正確的顯示出來:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
 
function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) {
    animal; // (parameter) animal: Fish | Human
  } else {
    animal; // (parameter) animal: Bird | Human
  }
}

instanceof 收窄

instanceof 也是一種型別保護,TypeScript 也可以通過識別 instanceof 正確的型別收窄:

image.png

賦值語句(Assignments)

TypeScript 可以根據賦值語句的右值,正確的收窄左值。

image.png

注意這些賦值語句都有有效的,即便我們已經將 x 改為 number 型別,但我們依然可以將其更改為 string 型別,這是因為 x 最初的宣告為 string | number,賦值的時候只會根據正式的宣告進行核對。

所以如果我們把 x 賦值給一個 boolean 型別,就會報錯:

控制流分析(Control flow analysis)

至此我們已經講了 TypeScript 中一些基礎的收窄型別的例子,現在我們看看在 if while等條件控制語句中的型別保護,舉個例子:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return new Array(padding + 1).join(" ") + input;
  }
  return padding + input;
}

在第一個 if 語句裡,因為有 return 語句,TypeScript 就能通過程式碼分析,判斷出在剩餘的部分 return padding + input ,如果 padding 是 number 型別,是無法達到 (unreachable) 這裡的,所以在剩餘的部分,就會將 number型別從 number | string 型別中刪除掉。

這種基於可達性(reachability) 的程式碼分析就叫做控制流分析(control flow analysis)。在遇到型別保護和賦值語句的時候,TypeScript 就是使用這樣的方式收窄型別。而使用這種方式,一個變數可以被觀察到變為不同的型別:

image.png

型別判斷式(type predicates)

在有的文件裡, type predicates 會被翻譯為型別謂詞。考慮到 predicate 作為動詞還有表明、宣告、斷言的意思,區分於型別斷言(Type Assertion),這裡我就索性翻譯成型別判斷式。

如果引用這段解釋:

In mathematics, a predicate is commonly understood to be a Boolean-valued function_ P_: _X_→ {true, false}, called the predicate on _X_.

所謂 predicate 就是一個返回 boolean 值的函式。

那我們接著往下看。

如果你想直接通過程式碼控制型別的改變, 你可以自定義一個型別保護。實現方式是定義一個函式,這個函式返回的型別是型別判斷式,示例如下:

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

在這個例子中,pet is Fish就是我們的型別判斷式,一個型別判斷式採用 parameterName is Type的形式,但 parameterName 必須是當前函式的引數名。

當 isFish 被傳入變數進行呼叫,TypeScript 就可以將這個變數收窄到更具體的型別:

// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim(); // let pet: Fish
} else {
  pet.fly(); // let pet: Bird
}

注意這裡,TypeScript 並不僅僅知道 if 語句裡的 petFish 型別,也知道在 else 分支裡,petBird 型別,畢竟 pet 就兩個可能的型別。

你也可以用 isFishFish | Bird 的陣列中,篩選獲取只有 Fish 型別的陣列:

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
 
// 在更復雜的例子中,判斷式可能需要重複寫
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});

可辨別聯合(Discriminated unions)

讓我們試想有這樣一個處理 Shape (比如 CirclesSquares )的函式,Circles 會記錄它的半徑屬性,Squares 會記錄它的邊長屬性,我們使用一個 kind 欄位來區分判斷處理的是 Circles 還是 Squares,這是初始的 Shape 定義:

interface Shape {
  kind: "circle" | "square";
  radius?: number;
  sideLength?: number;
}

注意這裡我們使用了一個聯合型別,"circle" | "square" ,使用這種方式,而不是一個 string,我們可以避免一些拼寫錯誤的情況:

function handleShape(shape: Shape) {
  // oops!
  if (shape.kind === "rect") {
    // This condition will always return 'false' since the types '"circle" | "square"' and '"rect"' have no overlap.
    // ...
  }
}

現在我們寫一個獲取面積的 getArea 函式,而圓和正方形的計算面積的方式有所不同,我們先處理一下是 Circle 的情況:

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2; // 圓的面積公式 S=πr²
  // Object is possibly 'undefined'.
}

strictNullChecks 模式下,TypeScript 會報錯,畢竟 radius 的值確實可能是 undefined,那如果我們根據 kind 判斷一下呢?

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
        // Object is possibly 'undefined'.
  }
}

你會發現,TypeScript 依然在報錯,即便我們判斷 kindcircle 的情況,但由於 radius 是一個可選屬性,TypeScript 依然會認為 radius 可能是 undefined

我們可以嘗試用一個非空斷言 (non-null assertion), 即在 shape.radius 加一個 ! 來表示 radius 是一定存在的。

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius! ** 2;
  }
}

但這並不是一個好方法,我們不得不用一個非空斷言來讓型別檢查器確信此時 shape.raidus 是存在的,我們在 radius 定義的時候將其設為可選屬性,但又在這裡將其認為一定存在,前後語義也是不符合的。所以讓我們想想如何才能更好的定義。

此時 Shape的問題在於型別檢查器並沒有方法根據 kind 屬性判斷 radiussideLength 屬性是否存在,而這點正是我們需要告訴型別檢查器的,所以我們可以這樣定義 Shape:

interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}
 
type Shape = Circle | Square;

在這裡,我們把 Shape 根據 kind 屬性分成兩個不同的型別,radiussideLength 在各自的型別中被定義為 required

讓我們看看如果直接獲取 radius 會發生什麼?

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
Property 'radius' does not exist on type 'Shape'.
  Property 'radius' does not exist on type 'Square'.
}

就像我們第一次定義 Shape 那樣,依然有錯誤。

當最一開始定義 radiusoptional 的時候,我們會得到一個報錯 (strickNullChecks 模式下),因為 TypeScript 並不能判斷出這個屬性是一定存在的。

而現在報錯,是因為 Shape 是一個聯合型別,TypeScript 可以識別出 shape 也可能是一個 Square,而 Square 並沒有 radius,所以會報錯。

但這時我們再根據 kind 屬性檢查一次呢?

image.png

你會發現,報錯就這樣被去除了。

當聯合型別中的每個型別,都包含了一個共同的字面量型別的屬性,TypeScript 就會認為這是一個可辨別聯合(discriminated union),然後可以將具體成員的型別進行收窄。

在這個例子中,kind 就是這個公共的屬性(作為 Shape 的可辨別(discriminant) 屬性 )。

這也適用於 switch 語句:
image.png

這裡的關鍵就在於如何定義 Shape,告訴 TypeScript,CircleSquare 是根據 kind 欄位徹底分開的兩個型別。這樣,型別系統就可以在 switch 語句的每個分支裡推匯出正確的型別。

可辨別聯合的應用遠不止這些,比如訊息模式,比如客戶端服務端的互動、又比如在狀態管理框架中,都是很實用的。

試想在訊息模式中,我們會監聽和傳送不同的事件,這些都是以名字進行區分,不同的事件還會攜帶不同的資料,這就應用到了可辨別聯合。客戶端與服務端的互動、狀態管理,都是類似的。

never 型別

當進行收窄的時候,如果你把所有可能的型別都窮盡了,TypeScript 會使用一個 never 型別來表示一個不可能存在的狀態。

讓我們接著往下看。

窮盡檢查(Exhaustiveness checking)


never 型別可以賦值給任何型別,然而,沒有型別可以賦值給 never (除了 never 自身)。這就意味著你可以在 switch 語句中使用 never 來做一個窮盡檢查。

舉個例子,給 getArea 函式新增一個 default,把 shape 賦值給 never 型別,當出現還沒有處理的分支情況時,never 就會發揮作用。

type Shape = Circle | Square;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

當我們給 Shape 型別新增一個新成員,卻沒有做對應處理的時候,就會導致一個 TypeScript 錯誤:

interface Triangle {
  kind: "triangle";
  sideLength: number;
}
 
type Shape = Circle | Square | Triangle;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      // Type 'Triangle' is not assignable to type 'never'.
      return _exhaustiveCheck;
  }
}

因為 TypeScript 的收窄特性,執行到 default 的時候,型別被收窄為 Triangle,但因為任何型別都不能賦值給 never 型別,這就會產生一個編譯錯誤。通過這種方式,你就可以確保 getArea 函式總是窮盡了所有 shape 的可能性。

TypeScript 系列

冴羽的全系列文章地址:https://github.com/mqyqingfeng/Blog

TypeScript 系列是一個我都不知道要寫什麼的系列文章,如果你對於 TypeScript 有什麼困惑或者想要了解的內容,歡迎與我交流,微信:「mqyqingfeng」,公眾號:「冴羽的JavaScript部落格」或者「yayujs」

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章