Typescript: 他實際上沒有驗證你的型別

2022/08/15閱讀時間約 8 分鐘
Typescript 是個好東西: 他讓你定義型別, 確保你的類別和函式保持特定的預期. 他強制讓你去思考你放進函式的資料是甚麼, 你從他得到甚麼. 如果理解錯誤, 試著要呼叫一個預期應該要是 string 的函式, 卻帶著 number 給他, 編譯器就會讓你知道. 所以說他是個好東西.
有時他會讓你誤解: 我遇到一個相信 typescript 保證型別就是你說的那樣. 但我必須告訴你: Typescript 不是這樣做的.
為何? Typescript 是在編譯層運作, 而不是在 runtime. 如果你看一下 Typescript 產生的程式碼長相, 你就會看到他轉換成 Javascript 然後抽離所有型別.
Typescript 程式碼:
const justAFunction = (n: number): string => {
return `${n}`
}

console.log(justAFunction)
Javascript 程式碼的結果(假設你是轉譯成比較近期的 EcmaScript 版本):
"use strict";
const justAFunction = (n) => {
return `${n}`;
};
console.log(justAFunction);
他只會根據你的來源程式碼是否正確來檢查型別. 他不會驗證真實資料.

檢查型別

那 typescript 沒用了嗎? 沒有, 還差得遠. 當你使用的方式正確, 會強制你檢查不確定的型別(不巧的是, 他還提供一些簡單的方式).
稍微改一下範例:
const justAFunction = (str: string[] | string): string => {
return str.join(' ')
}

console.log(justAFunction(["Hello", "World"]))
console.log(justAFunction("Hello World"))
當編譯時, 會導致以下錯誤:
index.ts:2:14 - error TS2339: Property 'join' does not exist on type 'string | string[]'.
Property 'join' does not exist on type 'string'.

2 return str.join(' ')
~~~~

Found 1 error in index.ts:2
編譯器強制認定 str 的變數型別. 其中一個解法是只允許 string[] 進到函式. 另一個解法是驗證變數包含正確型別.
const justAFunction = (str: string[] | string): string => {
if (typeof str === 'string') {
return str
}

return str.join(' ')
}

console.log(justAFunction(["Hello", "World"]))
console.log(justAFunction("Hello World"))
這也會轉譯成 Javascript, 然後型別就被驗證了. 這個情況下, 我們只會保證他是一個 string 而且我們只假設他是個陣列.
在大多數情況中, 這樣已足夠. 但是一旦我們需要處理額外的資料來源 — 像是 APIs, JSON 檔案, 使用者輸入 和 類似的東西 — 我們就無法認定資料的正確. 所以我們應該要驗證資料, 因而有機會確保正確的型態.

讓外部資料對照到你的型別

所以第一步要解決這個問題大概會是建立真實反應你資料的型別.
假設 API 回傳一個使用者紀錄像是這樣:
{
"firstname": "John",
"lastname": "Doe",
"birthday": "1985-04-03"
}
然後我們要為這份資料建立一個 interface:
interface User {
firstname: string
lastname: string
birthday: string
}
然後使用 fetch 從 API 取回使用者資料:
const retrieveUser = async (): Promise<User> => {
const resp = await fetch('/user/me')
return resp.json()
}
這樣做有效, 而且 typescript 會辨識使用者的型別. 但是他可能會騙你. 假設生日包含的是 timestamp 數字(可能對於 1970 以前出生的人會某些問題… 但不是現在的重點). 這個型別仍然會將生日視為一個 string, 雖然確實有數字在裡面… 然後 Javascript 會將他視為數字. 因為, 就像我們所說的一樣, Typescript 不會檢查真實的值.
我現在應該在做甚麼. 寫一個驗證函式. 可能像是這樣:
const validate = (obj: any): obj is User => {
return obj !== null
&& typeof obj === 'object'
&& 'firstname' in obj
&& 'lastname' in obj
&& 'birthday' in obj
&& typeof obj.firstname === 'string'
&& typeof obj.lastname === 'string'
&& typeof obj.birthday === 'string'
}

const user = await retrieveUser()

if (!validate(user)) {
throw Error("User data is invalid")
}
這樣做, 我們可以確保資料是如同他所宣稱的那樣. 但是你可能發現, 這樣做針對更複雜的案例很快就會失控.
有些 protocols 天生處理型別: gRPC, tRPC, 根據 schemaGraphQL 驗證 JSON(到特定程度上). 那些通常是針對特定使用情境. 我們可能需要更 general 的方式.

進入 Zod

Zod 是 Typescript 的型別之間失去的那個連結, 他強制驗證 Javascript 的型別. 他允許你定義結構, 推斷型別與驗證資料.
User 型別會被定義像是這樣:
import { z } from 'zod'

const User = z.object({
firstname: z.string(),
lastname: z.string(),
birthday: z.string()
})
然後型別可以從結構被抽出(被推斷).
const UserType = z.infer<User>
然後驗證會長得像這樣
const userResp = await retrieveUser()
const user = User.parse(userResp)
現在我們有型別和驗證過的資料, 而且我們需要寫的程式碼只比沒有驗證函式的程式碼稍微多一點.

結論

當運用 Typescript, 重要的是知道編譯器檢查和 runtime 驗證的差異. 要確保外部資料符合我們的型別, 我們需要在某處有做驗證. Zod 是很棒的工具, 正好是成本低且彈性的處理方式.
Chaol Liu
Chaol Liu
叫我 KO, 我是一名軟體工程師. 我的文章不只是單純的譯文(翻譯原文), 而是會以我的角度理解, 把語言之間的隔閡拆解, 讓你輕鬆學習到知識. 領域基本上無設限, 前端, 後端, DevOps 都會觸及.
留言0
查看全部
發表第一個留言支持創作者!