前一章已經找出目標系統的安全邊界與風險情境。其中一項常見問題,是程式在沒有確認內容的情況下,直接使用來自邊界外的資料。只要欄位缺漏、型別不同或內容超出預期,後續計算、狀態變更與輸出就可能產生錯誤。
資料來源看似可靠,也不代表每次取得的內容都符合目前程式。格式版本可能改變,既有紀錄可能包含歷史例外,執行設定也可能輸入錯誤。目標系統需要在資料進入主要處理前,先確認它符合本次功能接受的結構與規則。
本章會區分靜態型別檢查與執行時期驗證(Runtime Validation),說明如何使用資料結構描述(Schema)定義接受條件,並以 Zod 與 oRPC 示範 TypeScript 專案中的做法。最後再說明驗證後的資料如何進入內部模型,以及系統採用物件關聯對映(Object-Relational Mapping,ORM)時,如何和保存限制分工。
來源不明的資料,是指程式尚未確認其實際內容是否符合目前功能規格的值。判斷重點在於資料是否已經通過本次處理所需的檢查,不能只按照它來自系統外部或內部分類。
常見邊界包括:
如果系統具有表單、應用程式介面(Application Programming Interface,API)、訊息佇列或其他輸入方式,就要分別記錄各邊界的提供者、格式版本、必要欄位、大小限制與失敗結果。相同資料經過不同入口時,也要確認是否採用相同規則,避免其中一條路徑繞過檢查。
完成驗證的值只代表符合某一份資料結構描述。資料經過保存、轉換或傳送後,如果信任條件已經改變,下一個邊界仍要按照自己的契約重新檢查。
TypeScript 的靜態型別檢查會在開發與建置期間檢查程式如何使用資料,但型別標註不會在執行時期自動檢查收到的值。程式即使宣告 ImportRow,實際輸入仍可能缺少欄位,或把數值寫成字串。
型別斷言也不會產生驗證。下列程式只是要求 TypeScript 將 raw 視為 ImportRow,執行時期的內容不會因此改變:
type ImportRow = {
code: string
amount: number
}
function calculateAmount(raw: unknown): number {
const row = raw as ImportRow
return row.amount * 2
}
如果 raw 實際為 { amount: "5" },這段程式不會先拒絕輸入。JavaScript 的運算規則甚至可能暫時產生看似可用的結果,讓錯誤延後到其他步驟才被發現。
來源不明的值應該先標示為 unknown。unknown 允許程式接收任何值,但在縮小型別或完成驗證前,不能直接讀取屬性。相較之下,any 會略過多數型別檢查,錯誤內容容易在處理流程中繼續傳遞。TypeScript 文件對 unknown 與 any 的說明也指出,unknown 需要先經過型別縮小,才可以當成更具體的型別使用。
typeof、Array.isArray 與自訂型別守衛適合檢查少量且簡單的條件。當物件包含必要欄位、長度、範圍、列舉值與巢狀結構時,逐項手寫判斷容易遺漏,也不容易讓多個入口共用相同規則。這時可以使用資料結構描述集中定義接受條件。
資料結構描述要表達「這項功能接受甚麼」,內容不能只重現程式語言中的型別。除了 string、number 或陣列等基本型別,還要按照功能規格定義下列條件:
| 檢查類型 | 需要確認的內容 |
|---|---|
| 欄位存在狀態 | 區分必要、選用、可為空值及可以省略的欄位。 |
| 基本型別 | 確認字串、數值、布林值、陣列與物件沒有被其他型別取代。 |
| 格式與大小 | 限制字串格式、字元數、陣列項目數與巢狀層數。 |
| 數值範圍 | 確認整數、小數精度、最小值、最大值及是否接受非有限數值。 |
| 可接受值 | 使用列舉或可辨識聯集限制狀態、種類及對應欄位。 |
| 未知欄位 | 明確決定拒絕、移除或保留沒有定義的欄位。 |
| 欄位關係 | 確認開始與結束、種類與內容等跨欄位組合符合規則。 |
每一項限制都要能連回功能規格、格式契約或已確認的風險。把所有字串一律限制為相同長度,或拒絕所有未知欄位,不一定符合每個入口的相容需求。規則過鬆會讓錯誤內容繼續流入系統,規則過嚴則可能拒絕原本有效的輸入。
驗證時也要區分「檢查」與「轉換」。例如,來源字串 "12" 是否能轉成數值 12,要由該入口的契約決定。未經確認就自動轉型,可能把格式錯誤隱藏成有效內容。需要正規化(Normalization)時,應該明確定義空白、大小寫、日期、代碼與預設值的處理方式,並且測試轉換前後結果。
Zod 是 TypeScript 專案可採用的資料結構描述驗證函式庫。它能在執行時期解析輸入,並從同一份資料結構描述推導 TypeScript 型別。safeParse 會以可辨識聯集回傳成功資料或驗證問題,呼叫端不需要使用 try...catch 判斷一般輸入錯誤。
下列資料結構描述會檢查匯入內容的必要欄位、字串長度、數值範圍與狀態,也會拒絕沒有定義的欄位:
import * as z from 'zod'
const ImportRowSchema = z.strictObject({
code: z.string().trim().min(1).max(20),
amount: z.number().nonnegative(),
status: z.enum(['pending', 'confirmed']),
})
type ImportRowDto = z.infer<typeof ImportRowSchema>
function inspectImportRow(input: unknown) {
const result = ImportRowSchema.safeParse(input)
if (!result.success) {
return {
accepted: false as const,
issues: result.error.issues.map(issue => ({
path: issue.path.map(String).join('.'),
code: issue.code,
})),
}
}
return {
accepted: true as const,
value: result.data,
}
}
成功分支中的 result.data 才是 ImportRowDto。code 也已按照資料結構描述移除前後空白。呼叫端不需要再次使用型別斷言,也不能繞過失敗分支直接取得資料。
這個例子只示範一項輸入邊界。實際採用 Zod 時,還要確認函式庫版本、非同步規則、錯誤格式與效能是否符合目標技術組合。Zod 的基本用法文件分別說明 parse、safeParse 與非同步解析的行為,選擇方法時要按照驗證失敗是否屬於預期分支,以及資料結構描述是否包含非同步檢查決定。
資料結構描述不應該成為所有功能規則的集合。欄位型別、格式與組合條件適合在邊界檢查。需要讀取目前狀態、比較其他資料或決定功能結果的規則,則應該留在內部處理流程,避免驗證層同時承擔資料存取與狀態變更。
如果目標系統已經確認使用 TypeScript API,而且需要讓呼叫端與處理端共用程序契約,可以評估 oRPC。oRPC 支援 Zod、Valibot、ArkType 與其他符合標準資料結構描述介面的函式庫,能在程序上定義輸入與輸出驗證。
下列程序沿用前一節的 ImportRowSchema,讓處理函式收到的 input 已經完成執行時期驗證:
import { os } from '@orpc/server'
import * as z from 'zod'
const ImportRowSchema = z.strictObject({
code: z.string().trim().min(1).max(20),
amount: z.number().nonnegative(),
status: z.enum(['pending', 'confirmed']),
})
export const inspectImport = os
.input(ImportRowSchema)
.output(z.object({
code: z.string(),
accepted: z.boolean(),
}))
.handler(({ input }) => ({
code: input.code,
accepted: true,
}))
oRPC 的程序文件說明 .input 與 .output 可以執行資料結構描述驗證。這能減少呼叫契約與實作型別分開維護造成的差異,但仍要自行定義正確的欄位規則、錯誤結果與內部模型。API 以外的檔案、訊息、執行設定或遺留資料入口,也不會因為採用 oRPC 就自動受到保護。
呼叫端的驗證可以提早顯示問題,處理端仍然要保留自己的邊界檢查。系統可能還有其他輸入來源,呼叫端程式也可能使用不同版本。只有處理端能決定這次執行是否接受內容,不能把資料完整性依賴在呼叫端已經檢查。
驗證成功後,資料已經符合入口契約,但不代表它應該直接成為核心處理或保存使用的結構。不同階段的模型具有不同責任:
| 模型 | 主要責任 | 不應該承擔的責任 |
|---|---|---|
| 邊界資料或資料傳輸物件(Data Transfer Object,DTO) | 表達某個入口可接受與可回傳的欄位。 | 不應該直接決定內部所有狀態與保存欄位。 |
| 內部資料模型(Internal Data Model) | 表達功能規則需要的概念、狀態與不變條件。 | 不應該隨每個輸入格式或保存方式直接變動。 |
| 保存模型或 ORM 實體 | 表達資料如何對映至已確認的保存結構。 | 不應該作為來源不明資料的第一道驗證。 |
邊界資料通過驗證後,可以透過明確函式轉換成內部模型。轉換過程只接收已驗證型別,並且補上由目標系統產生的狀態:
type ImportRowDto = {
code: string
amount: number
status: 'pending' | 'confirmed'
}
type ImportCommand = {
referenceCode: string
amount: number
confirmed: boolean
}
function toImportCommand(input: ImportRowDto): ImportCommand {
return {
referenceCode: input.code,
amount: input.amount,
confirmed: input.status === 'confirmed',
}
}
明確轉換可以防止來源額外帶入內部欄位,也讓欄位改名、狀態轉換與預設值具有固定位置。如果不同入口使用不同格式,它們可以各自驗證並轉換成相同內部模型,核心規則不需要理解每種外部表示方式。
如果目標系統已經確認使用關聯式資料庫與 ORM,可以在保存模型中定義欄位型別、是否允許空值、唯一性、關聯與索引,並按照實際需求使用資料庫提供的完整性限制。這些限制能防止不符合保存規則的狀態寫入,但不能取代入口驗證。
兩者的分工如下:
不能因為 ORM 實體已經宣告型別,就直接把來源物件傳入建立或更新方法。ORM 可能只在寫入時回報錯誤,也可能接受程式語言層級可以表示、卻不符合功能規則的值。大量賦值或自動欄位對映還可能讓來源修改原本不該開放的保存欄位。
保存結構改變時,應該使用 ORM 或資料庫工具提供的遷移(Migration)機制管理版本,並在部署前確認程式模型與實際結構相容。遷移負責改變保存結構,不會自動修正既有內容的功能意義。需要轉換歷史資料時,仍要定義來源條件、轉換規則、錯誤紀錄與驗證方式。
驗證失敗是可預期的功能結果,不應該和未預期的系統例外混在一起。每個入口要定義失敗後是否拒絕整批內容、只拒絕特定項目,或允許修正後重新提供。
錯誤結果應該包含足以定位問題的資訊,例如欄位路徑、規則代碼與可以理解的說明。內部函式庫名稱、呼叫堆疊與保存結構不需要提供給輸入來源。不同入口也可以使用適合自身形式的結果,例如表單對應欄位、檔案對應列號,或批次處理產生拒絕項目清單。
記錄驗證失敗時,只保存分析問題所需的類型、欄位與識別資訊。不要把密碼、權杖、完整個人資料或整份原始輸入寫入執行紀錄。後續章節會再說明錯誤分類、結構化記錄與集中處理方式,本章只需要確保無效資料不會繼續進入主要流程。
驗證也要設定處理上限。即使每個欄位型別都正確,過長字串、過多陣列項目或過深巢狀結構仍可能占用超出預期的處理資源。大小與複雜度限制要按照實際功能需求決定,並在昂貴的解析與處理前盡早套用。
驗證測試要從資料結構描述與風險情境建立,不能只測試一筆正常資料。每個重要入口至少要確認:
如果同一份資料結構描述由多個入口共用,要分別測試每個入口是否確實呼叫驗證。只測試資料結構描述本身,仍可能漏掉某條路徑直接使用原始值。系統具有保存機制時,也要加入整合測試,確認保存限制會拒絕繞過一般入口的不完整狀態。
遺留資料轉換可以使用固定案例記錄已知例外,再確認每一類資料會被接受、轉換、隔離或拒絕。不能為了讓遷移成功就整體放寬目標系統入口規則,否則歷史例外會成為日後所有輸入都能使用的格式。
完成一項輸入邊界後,可以使用下列問題確認結果:
unknown 或等同的安全型別接收?any 直接相信輸入內容?unknown 接收,再經過型別縮小或資料結構描述驗證。