在 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.idpost.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 UnionsDiscriminate Types

延伸閱讀