Site icon May's Notes

為何無法如預期地在 TypeScript 中使用 Object.keys

TypeScript

TypeScript

原文:https://alexharri.com/blog/typescript-structural-typing

請看下面的例子,並試想為什麼會出現 Error?

const user = {
  name: 'test',
  age: 18,
  gender: 'female'
}

const keys = Object.keys(user)

keys.forEach(key => {
  if (user[key] == null) {
    //
  }
})
Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'UserData'.
  No index signature with a parameter of type 'string' was found on type 'UserData'.(7053)

如果你只是單純想知道怎麼解決上面的 Error,可以這麼改寫:

const keys = Object.keys(user) as (keyof typeof user)[]

const user = {
  name: 'test',
  age: 18,
  gender: 'female'
}

const keys = Object.keys(user) as (keyof typeof user)[]

keys.forEach(key => {
  if (user[key] == null) {
    //
  }
})

但這個錯誤似乎很荒謬,keys 明明是 user 的所有 property 為什麼遍歷 keys 卻無法知道 key 是什麼型別?

這就需要談到 Object.keys 的型別定義了。

keys(o: object): string[],可以看到返回值為 string[]

如果定義改為 keys<T extends object>(o: T): (keyof T)[]; 就能解決這個問題。這樣定義 Object.keys 似乎不費吹灰之力,但 TypeScript 卻不這樣做。原因與 TypeScript 的結構型態(Structural typing)有關。

TypeScript 中的結構型態

這就不得不提到 鴨子型別 Duck Typing,在程式設計中是動態型別的一種風格。在這種風格中,一個物件有效的語意,不是由繼承自特定的類或實現特定的介面,而是由「當前方法和屬性的集合」決定。

靜態型別:宣告變數時必須指定明確的型別,且型別一旦宣告後在程式執行時無法任意更換型別。如:JAVA, C, C++, C#…
動態型別:宣告變數時不用明確指定型別,且在程式執行時可以任意更換型別。如:JS, Python, PHP…

鴨子型別的名字來源於由詹姆斯·惠特科姆·萊利提出的鴨子測試,「鴨子測試」可以這樣表述:

當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就可以被稱為鴨子。

JavaScript 就是典型的鴨子型別語言。而 TypeScript 的結構型態是建立在鴨子型別的基礎之上,即 A 型別如果要兼容 B 型別,那麼 B 中至少要有和 A 相同的屬性

舉個例子,假設有一個型別 UserData,表示一個包含 nameage 兩個屬性的物件。當我們擁有一個型別為 UserData 的物件時,我們只知道它至少包含了 nameage 這兩個屬性,但我們無法確定是否還有其他屬性。

這就是所謂的「至少包含」的概念,而不是「確定完全等同於」,因此 saveUser(user1) 會出現 Error。

interface UserData {
  name: string
  age: number
}
const saveUser = (user: UserData) => {
  ...
}

const user1 = { name: 'Test' }
const user2 = { name: 'John', age: 20, gender: 'male' }
saveUser(user1)
saveUser(user2)

不安全的使用 Object.keys

如果今天需要寫一個方法來遍歷物件中的每個屬性是否通過驗證,可以使用 Object.keys

function validateUser(user: User) {
  let error = "";
  for (const key of Object.keys(user)) {
    const validate = validators[key];
    error ||= validate(user[key]);
  }
  return error;
}

但此方法的問題在於,該物件可能包含 validators 中不存在的屬性。

interface User {
  name: string;
  password: string;
}

const validators = {
  name: (name: string) => name.length < 1
    ? "Name must not be empty"
    : "",
  password: (password: string) => password.length < 6
    ? "Password must be at least 6 characters"
    : "",
};

function validateUser(user: User) {
  let error = "";
  for (const key of Object.keys(user)) {
    const validate = validators[key];
    error ||= validate(user[key]);
  }
  return error;
}

const user = {
  name: 'Alex',
  password: '1234',
  email: "alex@example.com",
};

validateUser(user);

要解決這個 Error 也很簡單,定義型別為 Array<keyof User> 即可:

function validateUser(user: User) {
  let error = "";
  for (const key of Object.keys(user) as Array<keyof User>) {
    const validate = validators[key];
    error ||= validate(user[key]);
  }
  return error;
}

但通過前面的說明,現在應該很容易理解為什麼上面例子中使用 Object.keys 會出現錯誤了。

善加利用結構型態

假設現在有一個函數 getKeyboardShortcut,該函數有一個參數 e 型別為 KeyboardEvent

function getKeyboardShortcut(e: KeyboardEvent) {
  if (e.key === "s" && e.metaKey) {
    return "save";
  }
  if (e.key === "o" && e.metaKey) {
    return "open";
  }
  return null;
}

寫一些 unit test 驗證一下

expect(getKeyboardShortcut({ key: "s", metaKey: true }))
  .toEqual("save");

expect(getKeyboardShortcut({ key: "o", metaKey: true }))
  .toEqual("open");

expect(getKeyboardShortcut({ key: "s", metaKey: false }))
  .toEqual(null);

此時會出現 Error,因為你必須傳入 KeyboardEvent 的 37 個 props 才行。

Type '{ key: string; metaKey: true; }' is missing the following properties from type 'KeyboardEvent': altKey, charCode, code, ctrlKey, and 37 more.

雖然可以使用 as KeyboardEvent 來解決這個 Error,但是可能會掩蓋可能發生的其他型別錯誤。

getKeyboardShortcut({ key: "s", metaKey: true } as KeyboardEvent);

所以我們可以自定義一個 interface KeyboardShortcutEvent,只包含我們需要傳入的 KeyboardEvent 中的其中兩個參數。這是可行的,因為 KeyboardEvent 是 KeyboardShortcutEvent 的超集。

interface KeyboardShortcutEvent {
  key: string;
  metaKey: boolean;
}

function getKeyboardShortcut(e: KeyboardShortcutEvent) {}

window.addEventListener("keydown", (e: KeyboardEvent) => {
  const shortcut = getKeyboardShortcut(e); // This is OK!
  if (shortcut) {
    execShortcut(shortcut);
  }
});

Reference

Exit mobile version