跳至主要内容

[TS] Type Guard and Narrowing

此篇為各筆記之整理,非原創內容,資料來源可見下方連結與文後參考資料。

Narrowing

Narrowing 指的是將某一個可能為多種型別的變數,縮限成某單一種型別。

透過 Type Guards 可以讓 TypeScript 在 compile 階段,避免開發者使用到不屬於該型別的方法,並且可以作為「當型別為 ... 才 ... 的操作」。

使用 in

in operator @ MDN

在 JavaScript 中,in 可以用來確保某一個屬性在特定的物件或其原型鏈中。

當某個變數的型別是其他型別的 Union 時,為了確保該變數的確是符合某個型別後,才能進行後續的操作:

"key" in x

  • 其中 x 是一個 Union Type 的「變數」(不是型別);key 則是預期在該型別內會有的屬性
  • 簡單想成 x 物件裡有屬性 "value"
// https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/
interface RGB {
red: number;
green: number;
blue: number;
}

interface HSV {
hue: number;
saturation: number;
value: number;
}

// color 有可能是 RGC | HSV
function setColor(color: RGB | HSV) {
// 使用 in 來做 Type Guard

// in 後面放的是「變數」而不是「型別」
// 如果 color 裡有 "hue" 則一定是 HSV
if ('hue' in color) {
// 'color' now has the type HSV
console.log(color.hue);
}
// ...
}

再看另一個範例:

type Cat = {
isMeow: boolean;
};

type Dog = {
isBark: boolean;
};

// Animal 有可能是 Cat 或 Dog
type Animal = Cat | Dog;

const getNoise = (animal: Animal) => {
// 使用 in 來做 Type Guard

// in 後加的是「變數」而不是「型別」
// 簡單想成是:animal 物件裡有屬性 "isBark",則一定是 Dog 型別
if ('isBark' in animal) {
console.log(animal.isBark); // 不會有 compile error
console.log('it is dog');
}

// 簡單想成是:animal 物件裡有屬性 "isMeow",則一定是 Cat 型別
if ('isMeow' in animal) {
// 已經斷言是貓,卻使用了 isBark 方法,compile 時就會報錯
console.log(animal.isBark); // Property 'isBark' does not exist on type 'Cat'.
console.log('it is cat');
}
};

使用 typeof 和 instances

const isUnknown: unknown = 'hello, unknown';

// build in type guard: typeof
if (typeof isUnknown === 'string') {
// 這裡可以確保 isUnknown 是 string
isUnknown.split(', ');
}

// build in type guard: instanceof
if (isUnknown instanceof Promise) {
// 這裡可以確保 isUnknown 是 Promise
isUnknown.then((x: unknown) => x).catch((err: Error) => err);
}

Discriminated Unions & Unreachable Error

Discriminated unions 是一種非常好用的方式,特別是在接收後端傳來的 response 時,我們常會需要根據某一個欄位的值來決定有其他哪些欄位,例如,status 是 OK 的話,則資料會帶有 payload,否則資料會帶有 error,則時候則可以利用 Discriminated Unions 的方式來達到。

Discriminated Unions 會定義多個不同的 Type,但這些 Type 中都會有一個共同的欄位,例如 status,以此欄位來判斷物件中會有其他哪些屬性。

enum STATUS_DESCRIPTION {
OK = 'OK',
ERROR = 'ERROR',
}

// 定義多個不同的 Type,但這些 Type 中都會有一個共同的欄位,例如 `status`
// 以此欄位來判斷物件中會有其他哪些屬性。
interface ISuccessResp {
status: STATUS_DESCRIPTION.OK;
payload: unknown;
}

interface IErrorResp {
status: STATUS_DESCRIPTION.ERROR;
errorCode: number;
description: string;
}

// 利用 Union 的方式產生 Union Type
type Resp = ISuccessResp | IErrorResp;

// CAUTION:不要在函式參數的地方做物件的解構,例如,不要 ({status, payload, errorCode, description} : Resp)
// 需要的話,可以在 switch case 判斷完後再來解構
const parseResponse = (resp: Resp) => {
switch (resp.status) {
case STATUS_DESCRIPTION.OK: {
// 透過 narrow 可以確定這裡的 resp 是 ISuccessResp 的型別
const { payload } = resp;
return payload;
}

case STATUS_DESCRIPTION.ERROR: {
// 透過 narrow 可以確定這裡的 resp 是 IErrorResp 的型別
const { errorCode, description } = resp;
return description;
}

default:
// 沒有判斷到的情況利用 exhaustiveness checking
// 避免有 case 在上面是沒有被定義到的
const _exhaustiveCheck: never = resp;
return _exhaustiveCheck;
}
};

User-defined type guards

:parameterName is TYPE

  • 要定義 user-defined type guard,只需要定義一個函式,其「回傳值」的型別會是 type predicate,例如 value is string
  • type predicate 都會長像這樣 :parameterName is TYPE,而這個 parameterName 一定要是該函式中參數的名稱
  • user-defined type guards 一定是回傳 boolean,當回傳的 boolean 是 true 時,TypeScript 就會將變數的型別 narrow 到某一型別。
  • TypeScript 並不會管實際上在函式內做了什麼判斷,它只會辨認最終回傳的是 truefalse,如果是 true,表示符合該變數符合該 type predicate

使用 typeof

/**
* user-defined type guards
* https://www.typescriptlang.org/docs/handbook/advanced-types.html#typeof-type-guards
*/
let isUnknown: unknown: "hello unknown";

function isNumber(x: any): x is number {
return typeof x === 'number';
}

function isString(x: any): x is string {
return typeof x === 'string';
}

if (isNumber(isUnknown)) {
// 這裡可以確保 isUnknown 是 number
isUnknown.toFixed();
}

if (isString(isUnknown)) {
// 這裡可以確保 isUnknown 是 string
isUnknown.split(', ');
}

透過 isFunction 來判斷某變數型別是否是 Function

// https://exploringjs.com/tackling-ts/ch_type-guards-ion-functions.html#user-defined-type-guards

// 讓某個函式回傳的型別是 Function
// value is Function 即是 type predicate
function isFunction(value: unknown): value is Function {
// TypeScript 並不管實際上做了什麼判斷,只要回傳的值 true 即表示回傳的變數是 Function(type predicate)
return typeof value === 'function';
}

實作 typeof 的 user-defined type guard

// https://exploringjs.com/tackling-ts/ch_type-guards-assertion-functions.html#a-first-attempt

function isTypeof<T>(value: unknown, prim: T): value is T {
if (prim === null) {
return value === null;
}
return value !== null && typeof prim === typeof value;
}

實作 isDefined 的 user-defined type guard

// 如果 arg 沒加上 undefined,則 filtered 的型別會推導成 (string | undefined)[]
// 但若是使用 arg: T | undefined,則 undefined 會從該型別被抽出,因此最終會推導成 string[]

function isDefined<T>(arg: T | undefined): arg is T {
return typeof arg !== 'undefined';
}

const list = ['foo', 'bar', undefined];
const filtered = list.filter(isDefined);

實作 isPresent 的 user-defined type guard

// Array filter 後的元素不會是 undefined 或 null

export function isPresent<T>(arg: T | undefined | null): arg is T {
return arg !== null && typeof arg !== 'undefined';
}

程式範例

What does the is keyword do in typescript? @ stackoverflow

// 程式碼來源:https://stackoverflow.com/a/45748366/5135452

// isString 是 Type Guard

type Cat = {
isMeow: boolean;
};

type Dog = {
isBark: boolean;
};

type Animal = Cat | Dog;

// Type Guard
function isDog(animal: Dog | Cat): animal is Dog {
return (animal as Dog).isBark === true;
}

const getNoise = (animal: Animal) => {
if (isDog(animal)) {
console.log(animal.isBark);
console.log('it is dog');
} else {
// console.log(animal.isBark); // 會報錯,不是 Dog 卻用了 dog 的方法
console.log(animal.isMeow);
console.log('it is cat');
}
};

const dog: Dog = {
isBark: true,
};

getNoise(dog);

Assertion-based type guards

另外還有一種是 assert-based type guards,用來斷言該變數一定是某型別,否則會拋出錯誤:

function assertIsStringArray(arr: any[]): asserts arr is string[] {
if (!arr) throw new Error('not an array!');
const hasNonString = arr.some((item) => typeof item !== 'string');
if (hasNonString) throw new Error('not an array of string!');
}

const list = ['foo', 'bar', undefined];
assertIsStringArray(list); // throw error here

const arr = ['3', '2', '21'];
assertIsStringArray(arr); // 不會報錯,程式會繼續執行
arr.join(', ');

Array filter 的問題

在 TypeScript 中,一般的 filter 並沒有辦法在型別上把 undefinednull 給過濾掉,舉例來說:

const arr = [1, 2, 3, null, undefined];
const filteredArr = arr.filter((item) => !!item); // (number | null | undefined)[]

filteredArr 最終被推導出來的型別仍然是 (number | null | undefined)[]

使用 Type Guard

雖然對於 primitive type 來說,可以很方便的使用 TypeGuard 來處理,例如:

const filteredArr = arr.filter((item): item is number => !!item); //  number[]

加上 item is number 的 Type Guard 後,就可以讓 TypeScript 縮限成對應的型別。

但如果是 array of object 的話,除非有另外定義出該 object 的型別,不然會變的比較複雜。例如:

const arr = [{ age: 10 }, { age: 20 }, { age: 30 }, null, undefined];

const filteredArr = arr.filter((item) => !!item); // ({ age: number } | null | undefined)[]

比較好的作法是可以定義 isDefinedisPresent 這類的 Type Guard:

  • [實作 isDefined 的 user-defined type guard](#實作 isDefined 的 user-defined type guard)
  • [實作 isPresent 的 user-defined type guard](#實作 isPresent 的 user-defined type guard)

使用 flatMap

這之後除非我們先另外定義出該文件的型別,否則是無法用 Type Guard 處理的。在 StackOverflow 上則有一個透過 flatMap 的處理方式,雖然有點像 workaround,但需要的時候不失為一個簡便的方法。

這種方式特別適合用在 map 後的 filter

const filteredArr = arr.flatMap((item) => (!item ? [] : [item])); // { age: number }[]

如此,利用 flatMap 的特性,TypeScript 將能推導出想要的型別。但要留意的是 flatMap 屬於 ES2019 後才支援的語法,這點在使用上仍須留意。