Skip to content
    在 Opshell 的 Blog 中:
  • 目前已分享 73 篇文章
  • 還有 180 個坑正在填補中
  • 已有: Loading 次觀看
  • 已有: Loading 個人來過
✍️ Opshell
📆 Last Updated:9/23/2025Created:2025-09-22
👀 已被閱讀: Loading

TypeScript 型別規範

本規範旨在建立一套以 Zod Schema 為核心的、清晰、可維護且易於團隊協作的 TypeScript 資料層撰寫標準。 核心思想是:從 SSoT Schema 衍生出一切。 基本情況是,當資料到達 UI 層要使用時,已經是經過嚴格驗證且符合 UI 需求的狀態,不額外花成本處理他的欄位狀態。

目標是,任何開發者看到一個 Schema 的名稱,就能立刻回答以下問題:

  1. 它的核心職責是什麼?(是核心模型、API Payload?)
  2. 它的資料流向是怎樣的?(是流向後端,還是來自後端?)
  3. 它的資料狀態是原始的還是處理過的?

設計原則

  1. 以 Zod 核心 Schema 為單一事實來源 (SSoT)

    前端內部使用的所有業務邏輯、驗證規則和資料模型,都應定義在一份核心 Zod Schema 中。 核心 Schema 是一切 API 型別(Parsers, Payloads, Params)的衍生來源。

  2. 由內而外 (Bottom-Up)

    巢狀 Schema 先定義最深層,逐層組合。

  3. 語意化命名

    Schema 與其衍生型別的命名應能清楚表達業務實體與責任。

  4. 避免魔法數字

    使用 z.number() 定義寫死在前端或 UI 使用的有意義數字時,使用 z.enum()z.nativeEnum() 取代單純的數字,賦予業務語意。

    ts
    export 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)
    });

Schema 與型別命名規則

  1. 統一使用 PascalCase

    • Schema:CategorySchema
    • 型別:type Category = z.infer<typeof CategorySchema>
  2. 後綴規則

    • ...:該 Schema.infer 的核心型別結構,例如 Category
    • ...Schema:核心實體 (SSoT),例如 CategorySchema
    • ...ParamsGET API 請求參數(Query or Path params),例如 GetCategoryListParams
    • ...PayloadPOST/PUT/PATCH API 請求體 (Body),例如 UpdateCategoryPayload
    • ...Parser:API 回應解析器(從 Raw → camelCase → 驗證 → 核心 Schema),例如 UpdateCategoryParser
    • ...RawSchema:後端原始回應結構(snake_case,允許 optional/nullable),通常只用來衍生 ...Parser
    • ...Input:該 Schema.input 的輸入型別,通常是 ParamsPayload 的輸入狀態。
    • ...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 相關工具。
  1. src/features/{featureName}/schemas/{featureName}.schema.ts

    • 用途最重要的檔案。定義與特定功能模組相關的所有 Zod Schema,包括核心 Schema (SSoT)、Params, Payloads 和 Parsers。
    • 原則總是為 schema 建立一個 schemas 資料夾,即使初期只有一個檔案。這確保了專案結構的一致性和可預測性。
  2. src/utils/zod.ts

    • 用途:放置與 Zod 相關的全域輔助函式。
    • 放置內容snakeToCamel, camelToSnake, createParser 工廠函式等。

檔案內部結構與排序

為了提升 *.schema.ts 檔案的可讀性和可維護性,應遵循「SSoT先行,Action分明」的原則進行排序。

  1. 核心 SSoT Schema 置頂:檔案中最重要的核心 ...Schema 和其 infer 型別應放在檔案的最上方。
  2. 以 API Action 為單位分組:其餘的 schema 應按照 API 端點/功能(例如:Get List, Update Item)進行分組,並使用註解明確標示。
  3. 組內遵循「請求 → 回應」順序:在每個 Action 群組內部,先定義請求相關的 ...Params/...Payload,再定義回應相關的 ...RawSchema/...Parser,最後集中導出 ...Input/...Output 型別。

衍生與組合原則

  1. 核心 Schema (SSoT)

    定義 camelCase + 嚴格驗證規則。

  2. Params / Payload (請求)

    獨立定義或從 CoreSchema.pick() / .omit()z.coerce 處理型別 → .transform(camelToSnake)

  3. Parser (回應)

    定義 RawSchema.transform(snakeToCamel).pipe(CoreSchema)

  4. 型別輸出

    使用 z.input 獲取轉換前的型別,用於函式參數;使用 z.output 獲取轉換後的型別,用於 API 回應。

  5. 單一職責

    不同情境需求應建立新的 Schema,而不是在同一個 Schema 上附加過多條件邏輯。

注意事項

  • RawSchema 可寬鬆(nullable, optional),但 ParserCoreSchema .pipe() 後必須嚴格。
  • Schema 命名用 PascalCase,欄位用 camelCase
  • .schema.ts 檔案內應保持純粹,不應 import Vuerefcomposable 等。如有處理與 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. 核心實體層 (Entity Layer) - *Schema

    這是我們系統的基石,前端世界裡的 單一事實來源 (SSoT)。它定義了應用程式中最核心、最純粹的資料模型,並應符合前端最理想的使用形態。

  2. 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

範例

ts
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>;
ts
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}

總結

層級職責命名範例TS 型別範例
Entity核心 SSoTCategorySchemaCategory
APIGET 請求參數GetCategoryListParamsGetCategoryListInput
APIPOST/PUT 請求體UpdateCategoryPayloadUpdateCategoryInput
API解析後端的回應GetCategoryListParserGetCategoryListOutput

Released under the MIT License.