Hoi hoi! 👋

我是 @nyaomaru,一位沉迷於 gamba 的前端工程師。😸🦐

最近,我在思考一個非常常見的 TypeScript 問題。

假設某個值來自:

  • API 回應
  • JSON.parse
  • 本機儲存
  • 訊息 payload
  • 第三方程式碼

在我們真正驗證它之前,它應該是什麼型別?

大概是這個

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 變成字串。

所以如果執行期信任很重要,型別斷言並不是驗證。

我們需要真正的執行期檢查。


🛡️ 型別守衛把執行期與 TypeScript 接起來

先從小地方開始。

使用 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
→ 帶標記的結果 + 驗證過的值

相同的執行期規則。

不同的控制流程形狀。


🧃 JSON 呢?

這是最常見的邊界之一。

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 函式庫之間有些重疊,但它們並不是在解決完全相同的問題。

例如,當我們需要以下功能時,schema 函式庫通常是更好的選擇:

  • 帶有欄位路徑的結構化驗證錯誤
  • 一次顯示多個驗證問題
  • 型別轉換
  • 預設值
  • 轉換
  • schema 產生型別或外部工件
  • 與表單或 API 框架深度整合

這些都是更大型的驗證工作流程。

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


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝11   💬1  
347
🥈
mike
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登