Hoi hoi! 👋
我是 @nyaomaru,一位沉迷於 gamba 的前端工程師。😸🦐
最近,我在思考一個非常常見的 TypeScript 問題。
假設某個值來自:
JSON.parse在我們真正驗證它之前,它應該是什麼型別?
大概是這個
declare const input: unknown;
這部分很簡單。
有趣的問題是接下來怎麼辦。
我們要怎麼安全地把這個 unknown 轉成有用的東西?
而且,每次都真的需要一個完整的 schema 函式庫嗎?
一起看看吧!👀
https://is-kit.dev/guides/validate-unknown-without-schema-library
unknown 正在盡它的職責假設我們有
declare const input: unknown;
input.name;
// Error
TypeScript 不允許我們存取 name。
一開始這可能會讓人覺得很煩。
但 TypeScript 其實是在告訴我們一件很重要的事
我們有一個值。
但我們還不知道它是什麼。
這正是未受信任邊界值應該表達的意思。
HTTP 回應不會因為我們的應用程式期待 User,就自動變成 User。
解析後的 JSON 也不會。
所以我喜歡這樣理解 unknown
unknown不是缺少型別資訊。它是在誠實地描述一個我們還沒驗證過的值。
問題不在 unknown。
問題在於我們要如何建立信任。
as User有一條很短的路可以出去。
type User = {
id: string;
name: string;
};
declare const input: unknown;
const user = input as User;
user.name.toUpperCase();
TypeScript 很開心。
太棒了!🎉
不過,其實什麼都沒有被檢查。
這樣依然可行
const input = {
id: "user-1",
name: 123,
} as unknown;
const user = input as User;
user.name.toUpperCase();
// 💥 執行期錯誤
as User 改變的是編譯器的認知。
它不會檢查執行期的值。
它不會補上缺少的屬性。
它也不會把 123 變成字串。
所以如果執行期信任很重要,型別斷言並不是驗證。
我們需要真正的執行期檢查。
先從小地方開始。
使用 is-kit,我們可以這樣建立一個守衛
import { isString, oneOfValues, struct } from "is-kit";
const isUser = struct({
id: isString,
name: isString,
role: oneOfValues("admin", "member"),
});
現在我們可以直接把 unknown 傳給它
declare const input: unknown;
if (isUser(input)) {
input.name.toUpperCase();
// input:
// Readonly<{
// id: string;
// name: string;
// role: "admin" | "member";
// }>
}
這就是我喜歡的地方。
同一個檢查同時做兩件事
執行期
→ 這個值真的符合嗎?
TypeScript
→ 檢查成功後,這個值是什麼?
沒有另外的 schema 定義,也沒有型別斷言。
我們只是組合小型的執行期 predicate。
實際的 payload 通常會稍微複雜一點。
也許一個使用者有:
null 的簡介我們可以繼續組合守衛
import {
arrayOf,
isString,
nullable,
oneOfValues,
optionalKey,
struct,
} from "is-kit";
const isProfile = struct({
displayName: isString,
bio: optionalKey(nullable(isString)),
});
const isUser = struct({
id: isString,
role: oneOfValues("admin", "member"),
profile: optionalKey(isProfile),
tags: arrayOf(isString),
});
接著
isUser({
id: "user-1",
role: "member",
tags: ["typescript", "guards"],
});
// true
但是
isUser({
id: "user-1",
role: "owner",
tags: ["typescript"],
});
// false
每一部分仍然只是一個守衛。
這表示我們可以單獨測試它、在別處重用它,或把它再組合成更大的東西。
這裡還有一個重要差異
optionalKey(...)
表示
這個屬性可以不存在
而
nullable(...)
表示
這個值可以是 null
這兩種是不同的執行期契約,所以我偏好把它們分開。
safeParse 多了什麼?直接使用守衛通常就夠了。
if (isUser(input)) {
renderUser(input);
}
但有時候我希望驗證結果本身能在程式流程中傳遞。
例如
const result = safeParse(isUser, input);
if (!result.valid) {
return { status: 400 as const };
}
return {
status: 200 as const,
user: result.value,
};
safeParse 會給我們一個帶標記的小結果
{
valid: (true, value);
}
或是
{
valid: false;
}
所以 TypeScript 可以用一般的分支來縮小型別。
重點是 safeParse 不會做什麼。
它不會:
成功時,它會在守衛接受後回傳同一個值。
所以我會這樣理解它們的差異
guard
→ boolean + 型別縮小
safeParse
→ 帶標記的結果 + 驗證過的值
相同的執行期規則。
不同的控制流程形狀。
這是最常見的邊界之一。
const value = JSON.parse(body);
尷尬的是,JSON.parse 回傳的是 any。
所以這個不安全的值可能會在我們驗證之前就擴散到整個程式中。
因此,我們可以把邊界維持得很小
import { safeJsonParse } from "is-kit";
declare const body: string;
const result = safeJsonParse(body, isUser);
if (!result.valid) {
return { status: 400 as const };
}
renderUser(result.value);
概念上,流程是這樣
JSON 文字
↓
解碼
↓
unknown
↓
守衛
↓
已驗證的值
無效的 JSON 與守衛失敗都會產生
{
valid: false;
}
同樣地,不會為了讓值通過而偷偷做轉換。
我喜歡這樣,因為不安全的部分就留在解碼邊界。
在物件邊界上,還有另一個小設計選擇。
假設我們預期的是
{
username: string;
password: string;
}
那這個也應該被接受嗎?
{
username: "Neko",
password: "secret",
admin: true,
}
使用 struct 時,預設會允許額外的鍵。
這通常對 API 回應很有用,因為伺服器可能新增欄位而不會破壞舊版客戶端。
但有時候我們真的想要封閉的物件形狀。
這時可以使用
const isCredentials = struct(
{
username: isString,
password: isString,
},
{ exact: true },
);
現在
isCredentials({
username: "Neko",
password: "secret",
});
// true
而
isCredentials({
username: "Neko",
password: "secret",
admin: true,
});
// false
重點不是某一種模式永遠比較好。
而是邊界應該表達出我們真正想要的契約。
需要。
有時候非常需要。
型別守衛工具與 schema 函式庫之間有些重疊,但它們並不是在解決完全相同的問題。
例如,當我們需要以下功能時,schema 函式庫通常是更好的選擇:
這些都是更大型的驗證工作流程。
is-kit 想回答的是一個更窄的問題
這個值是否符合這個執行期 predicate?
如果是,TypeScript 可以安全地把它縮小成什麼?
這個較窄的模型有時候正是我想要的。
例如
| 方法 | 適合情境 |
|---|---|
內嵌 typeof 檢查 |
一個小型的本地條件判斷 |
| 型別守衛 | 可重用的布林驗證與型別縮小 |
| Schema 函式庫 | 豐富驗證、轉換與 schema 工作流程 |
所以我不認為問題是
型別守衛還是 schema 函式庫——哪個贏?
更有用的問題是
解決這個邊界所需的最小驗證模型是什麼?
我一開始拿到的是一個簡單的值
declare const input: unknown;
誘惑人的捷徑是
const user = input as User;
但那只是在改變 TypeScript 的信念。
它並沒有建立執行期信任。
執行期守衛提供的是更有用的流程
unknown
↓
執行期檢查
↓
縮小後的值
而當我們想要一個以結果為導向的 API 時
unknown
↓
guard
↓
safeParse
↓
{ valid: true, value }
or
{ valid: false }
沒有隱藏的轉換。
沒有假裝檢查早就發生過的斷言。
只有簡單的執行期規則,而 TypeScript 也能理解它們。😸
如果你想看完整指南,包括 JSON 邊界、精確物件,以及自訂領域條件:
https://is-kit.dev/guides/validate-unknown-without-schema-library
如果你想進一步了解 is-kit 本身:
https://github.com/nyaomaru/is-kit
如果你覺得有用,歡迎到 GitHub 點個 ⭐!
感謝閱讀!🙌
原文出處:https://dev.to/nyaomaru/how-do-you-validate-unknown-in-typescript-without-a-schema-library-1a11