TypeScript 型別規範
本規範旨在建立一套以 Zod Schema 為核心的、清晰、可維護且易於團隊協作的 TypeScript 資料層撰寫標準。 核心思想是:從 SSoT Schema 衍生出一切。 基本情況是,當資料到達 UI 層要使用時,已經是經過嚴格驗證且符合 UI 需求的狀態,不額外花成本處理他的欄位狀態。
目標是,任何開發者看到一個 Schema 的名稱,就能立刻回答以下問題:
- 它的核心職責是什麼?(是核心模型、API Payload?)
- 它的資料流向是怎樣的?(是流向後端,還是來自後端?)
- 它的資料狀態是原始的還是處理過的?
設計原則
以 Zod 核心 Schema 為單一事實來源 (SSoT)
前端內部使用的所有業務邏輯、驗證規則和資料模型,都應定義在一份核心 Zod Schema 中。 核心 Schema 是一切 API 型別(Parsers, Payloads, Params)的衍生來源。由內而外 (Bottom-Up)
巢狀 Schema 先定義最深層,逐層組合。
語意化命名
Schema 與其衍生型別的命名應能清楚表達業務實體與責任。
避免魔法數字
使用
z.number()定義寫死在前端或 UI 使用的有意義數字時,使用z.enum()或z.nativeEnum()取代單純的數字,賦予業務語意。tsexport const CategorySchema = z.object({ id: z.number(), parentId: z.number(), title: z.string().min(1, '題目為必填'), type: z.enum(['color', 'material']), // 取代 type: z.string() enable: z.boolean().default(true) });1
2
3
4
5
6
7
Schema 與型別命名規則
統一使用 PascalCase
- Schema:
CategorySchema - 型別:
type Category = z.infer<typeof CategorySchema>
- Schema:
後綴規則
...:該Schema.infer的核心型別結構,例如Category。...Schema:核心實體 (SSoT),例如CategorySchema。...Params:GETAPI 請求參數(Query or Path params),例如GetCategoryListParams。...Payload:POST/PUT/PATCHAPI 請求體 (Body),例如UpdateCategoryPayload。...Parser:API 回應解析器(從 Raw → camelCase → 驗證 → 核心 Schema),例如UpdateCategoryParser。...RawSchema:後端原始回應結構(snake_case,允許 optional/nullable),通常只用來衍生...Parser。...Input:該Schema.input的輸入型別,通常是Params或Payload的輸入狀態。...Output:該Schema.output的輸出型別,通常是Parser的輸出狀態。
檔案結構
檔案結構應遵循 feature-sliced 的原則,將與特定功能相關的檔案放在一起。
src/
├─ features/{featureName}/
│ ├─ apis/
│ ├─ components/
│ ├─ hooks/
│ ├─ services/
│ └─ schemas/
│ ├─ {featureName}.schema.ts
│ └─ {subContext}.schema.ts
├─ types/
│ ├─ common.ts // 共用 interface 或 type(非 Zod)。
│ └─ globals.d.ts // 全域環境變數與型別工具。
└─ utils/
└─ zod.ts // Zod 相關工具。2
3
4
5
6
7
8
9
10
11
12
13
14
src/features/{featureName}/schemas/{featureName}.schema.ts- 用途:最重要的檔案。定義與特定功能模組相關的所有 Zod Schema,包括核心 Schema (SSoT)、Params, Payloads 和 Parsers。
- 原則:總是為 schema 建立一個
schemas資料夾,即使初期只有一個檔案。這確保了專案結構的一致性和可預測性。
src/utils/zod.ts- 用途:放置與 Zod 相關的全域輔助函式。
- 放置內容:
snakeToCamel,camelToSnake,createParser工廠函式等。
檔案內部結構與排序
為了提升 *.schema.ts 檔案的可讀性和可維護性,應遵循「SSoT先行,Action分明」的原則進行排序。
- 核心 SSoT Schema 置頂:檔案中最重要的核心
...Schema和其infer型別應放在檔案的最上方。 - 以 API Action 為單位分組:其餘的 schema 應按照 API 端點/功能(例如:Get List, Update Item)進行分組,並使用註解明確標示。
- 組內遵循「請求 → 回應」順序:在每個 Action 群組內部,先定義請求相關的
...Params/...Payload,再定義回應相關的...RawSchema/...Parser,最後集中導出...Input/...Output型別。
衍生與組合原則
核心 Schema (SSoT)
定義
camelCase+ 嚴格驗證規則。Params / Payload (請求)
獨立定義或從
CoreSchema中.pick()/.omit()→z.coerce處理型別 →.transform(camelToSnake)。Parser (回應)
定義
RawSchema→.transform(snakeToCamel)→.pipe(CoreSchema)。型別輸出
使用
z.input獲取轉換前的型別,用於函式參數;使用z.output獲取轉換後的型別,用於 API 回應。單一職責
不同情境需求應建立新的 Schema,而不是在同一個 Schema 上附加過多條件邏輯。
注意事項
RawSchema可寬鬆(nullable,optional),但Parser經CoreSchema.pipe()後必須嚴格。- Schema 命名用
PascalCase,欄位用camelCase。 .schema.ts檔案內應保持純粹,不應importVue 的ref、composable等。如有處理與 UI 狀態耦合的驗證需求,應透過工廠函式模式處理。ts// Base schema (static rules) export const LoginFormSchema = z.object({ /* ... */ }); /** * Factory function for dynamic rules * @param captcha - The correct captcha string from UI state */ export function createLoginSchema(captcha: string) { return LoginFormSchema.refine( data => data.captcha.toUpperCase() === captcha.toUpperCase(), { message: '驗證碼錯誤!', path: ['captcha'] } ); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
操作細則 & 說明
核心實體層 (Entity Layer) -
*Schema這是我們系統的基石,前端世界裡的 單一事實來源 (SSoT)。它定義了應用程式中最核心、最純粹的資料模型,並應符合前端最理想的使用形態。
API 互動層 (API Layer) -
*Params,*Payload,*Parser此層專門處理與後端 API 溝通時的資料轉換與驗證。
...Params(GET 請求參數)- 職責:定義
GET請求的 URL 查詢參數或路徑參數。 - 要點:大量使用
z.coerce進行型別轉換,並善用.optional()和.default()。 - 命名:
Get{EntityName}ListParams,Get{EntityName}Params
- 職責:定義
...Payload(POST/PUT/PATCH 請求體)- 職責:定義發送給後端的請求體(Body)形狀。
- 命名:
{Action}{EntityName}Payload
...Parser(API 回應)- 職責:驗證並解析從後端請求回來的原始資料 (
RawSchema),並將其轉換成符合我們核心實體 (*Schema) 的形狀。 - 命名:
{Action}{EntityName}Parser
- 職責:驗證並解析從後端請求回來的原始資料 (
範例
import { camelToSnake, snakeToCamel } from '@/utils/zod';
import { z } from 'zod';
// =================================================================
// 1. 核心 SSoT (Core SSoT)
// =================================================================
export const CategorySchema = z.object({
id: z.number().int().positive(),
title: z.string().min(1, '分類標題為必填'),
sort: z.number().int(),
parentId: z.number().int().default(0),
isEnabled: z.boolean().default(true)
});
export type Category = z.infer<typeof CategorySchema>;
// =================================================================
// API: Get Category List
// =================================================================
// [-] Outgoing: 請求參數
export const GetCategoryListParams = z
.object({
isEnabled: z.coerce.boolean().optional(),
search: z.string().optional(),
page: z.coerce.number().int().positive().default(1)
})
.transform(camelToSnake);
// [-] Incoming: API 回應
const CategoryListItemRawSchema = z.object({
id: z.number(),
title: z.string(),
sort: z.number(),
parent_id: z.number(),
is_enabled: z.boolean()
});
export const GetCategoryListParser = z.array(CategoryListItemRawSchema)
.transform(snakeToCamel)
.pipe(z.array(CategorySchema));
// [-] Types
export type GetCategoryListInput = z.input<typeof GetCategoryListParams>;
export type GetCategoryListOutput = z.output<typeof GetCategoryListParser>;
// =================================================================
// API: Update Category
// =================================================================
// [-] Outgoing: 請求體
export const UpdateCategoryPayload = CategorySchema.pick({
id: true,
title: true,
sort: true,
isEnabled: true
}).transform(camelToSnake);
// [-] Incoming: API 回應
const UpdateCategoryRawSchema = CategoryListItemRawSchema; // 假設更新後回傳的 Raw 結構與列表項相同
export const UpdateCategoryParser = UpdateCategoryRawSchema
.transform(snakeToCamel)
.pipe(CategorySchema);
// [-] Types
export type UpdateCategoryInput = z.input<typeof UpdateCategoryPayload>;
export type UpdateCategoryOutput = z.output<typeof UpdateCategoryParser>;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import { z } from 'zod';
import { CategorySchema } from './category.schema';
// [P] 核心 Schema (SSoT)
// 衍生自 CategorySchema,並擴充 UI 相關狀態
export const AnswerCategorySchema = CategorySchema
.pick({ id: true, title: true })
.extend({
isChecked: z.boolean().default(false)
});
export type AnswerCategory = z.infer<typeof AnswerCategorySchema>;
// 如果 AnswerCategory 需要獨立的 API 進行 CRUD,
// 則可依此類推建立其 Params, Payload, Parser。
// 命名規則為:{Action}AnswerCategory{Suffix}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
總結
| 層級 | 職責 | 命名範例 | TS 型別範例 |
|---|---|---|---|
| Entity | 核心 SSoT | CategorySchema | Category |
| API | GET 請求參數 | GetCategoryListParams | GetCategoryListInput |
| API | POST/PUT 請求體 | UpdateCategoryPayload | UpdateCategoryInput |
| API | 解析後端的回應 | GetCategoryListParser | GetCategoryListOutput |

