使用 Branded Types 增加 TypeScript 運行安全性
在 TypeScript 中,增加運行時安全性的一種方法是使用所謂的 Branded Types ,特別是用於區分具有相同基本型別但代表不同概念的值。例如,使用字串表示用戶ID和文章ID時,儘管它們在技術上都是字串,但它們在應用程序的上下文中具有截然不同的意義。
通過將它們標記為不同的 Branded Types,TypeScript 能夠識別和防止將一種ID錯誤地用作另一種,從而減少錯誤並提高代碼的可維護性和可讀性。
這種方法特別適用於解決以下情況:
async function getCommentsForPost(postId: string, authorId: string) {
const response = await api.get(
`/author/${authorId}/posts/${postId}/comments`
)
return response.data
}
const comments = await getCommentsForPost(user.id,post.id) // This is ok for Typescript
在這個例子中,user.id
和 post.id
的傳入順序錯誤了。問題在於,即使 User["id"]
和 Post["id"]
都是字串,但它們應該代表不同的概念。在 TypeScript 中,這些型別是可互換的,因此無法捕捉到這種錯誤。
為了解決這個問題,我們可以引入 Branded Type。這是一種特殊的型別,它基於一個基本型別(如 string),但帶有一個特殊的屬性,以區分不同的概念,例如:
type Brand<K, T> = K & { __brand: T }
type UserID = Brand<string, "UserId">
type PostID = Brand<string, "PostId">
使用這些 Branded Types,我們可以更精確地捕捉到錯誤:
// ❌ This fails since `user.id` is of type UserID and no PostID
// as expected
const comments = await getCommentsForPost(user.id,post.id)
// ^Argument of type 'UserID' is not assignable to parameter of
// type 'PostID'.
// Type 'UserID' is not assignable to type '{ __brand: "PostId"; }'.
// Types of property '__brand' are incompatible.
// Type '"UserId"' is not assignable to type '"PostId"'.
對於更複雜的應用場景,可以使用像是 effect-ts Schema 這樣的函式庫來提供更強大的功能。例如:
import type * as B from "effect/Brand"
type UserId = string & B.Brand<"UserId">
type Username = string
const getUser = (id: UserId) => { ... }
const myUsername: Username = "gcanti"
getUser(myUsername) // error
通過這種方法,我們可以在函數調用時避免傳遞錯誤型別的值,從而提高代碼的安全性和可靠性。但真正開發時,型別要細分到多細,是比較需要苦惱的問題。分太細,花時間,分太粗,似乎就不太需要,目前我沒有答案。
我現在並沒有在我的專案使用 Branded Types,而是直接採用 Effect-ts Schema。這個選擇為我的專案帶來了多重好處。首先,Effect-ts Schema 強大的功能使我能夠在靜態時期(static time)和運行時(runtime)有效捕捉錯誤。這意味著我可以更早地識別出潛在的問題,從而提高代碼的穩定性和可靠性。
其次,Effect-ts Schema 也提供了強大的表單驗證功能。這對於確保用戶輸入的數據符合預期格式和條件至關重要,有助於提升用戶體驗並減少數據處理中的錯誤。然後,Effect-ts Schema 還支援與 Database 交互時的序列化和反序列化。這使得數據的存取更加高效和安全,尤其是在處理複雜數據結構和大型數據集時。
實作方式見是先定義一個 Schema,再根據這個 Schema 推算出型別。而所有的值都透過 parse 來建構,以確保我們始終拿到預期的資料結構,通常是一個巢狀的 object。
const UserSchema = S.struct({
name: S.string,
age: S.number.pipe(S.int(), S.positive())
});
type User = S.Schema.To<typeof UserSchema>
然後一個 function 的 signature 是這樣:
declare function f(user: User): void;
便可以補抓到型別錯誤,確保傳遞進來的 object
的 property 跟 value 都是我們要的。
// 建立一個 effect 將一個 pure object,解析成 User type。
const getValidUser = S.parse(UserSchema)({
name: "bob",
age: 14
});
// 執行該 effect 取得型別是 User 的值。
const bob = Effect.runSync(getValidUser) // {name: "bob", "age"}
f(bob); // ok!
f({name: "bob"}) // err!
其他類似的做法有 Discriminated Unions 、Discriminate Types。