gwy-exam/deliverables/architecture/decisions/ADR-004-ts-strict-zod.md
2026-08-26 16:20:55 +08:00

3.0 KiB
Raw Blame History

ADR-004: 全 TypeScript strict + zod 运行时校验,全程禁止 any

Status: Accepted (2026-08-26)

Background

用户硬性要求:全 TypeScript全程禁止 any(评审铁律)。同时本项目数据来自本地 JSON 文件——文件是可变的外部输入可能被手工编辑、被旧版本程序写过、Docker 挂载来源不可控),若不运行时校验,读入的数据无法保证符合领域类型。

Decision

  1. tsconfig 全开严格选项strict 全家桶): strict / noImplicitAny / strictNullChecks / strictFunctionTypes / exactOptionalPropertyTypes / useUnknownInCatchVariables / noUncheckedIndexedAccess / noImplicitReturns / noUnusedLocals...
  2. 类型定义集中 + 单一契约源:领域类型由 packages/shared/src/schemas/*.schema.ts 的 zod schema 经 z.infer 产出(不手写 interface前后端共用统一响应 ApiResponse<T> = ApiSuccess<T> | ApiFailure泛型0 any
  3. 错误用联合类型AppError 带错误码联合 + messagecatch 变量为 unknown,用类型守卫窄化,不 any 强转
  4. zod 运行时校验获得类型收窄:数据来自 JSON 文件,用 zod schema parse(raw)(返回类型即 T天然收窄。这是「从文件读数据 + 无 any」的最优实践。z.infer 产出具名类型。
  5. OpenAPI 契约同源openapi.yaml 由 zod-openapi 从同一批 zod schema 自动反推生成,前端据此生成 TS 类型,无需另维护一份手写契约。

zod 示例(纯 TS无 any

import { z } from 'zod';
const ModuleKeySchema = z.enum(['xingce-shuli', 'xingce-panduan', /** ... */]);
export type ModuleKey = z.infer<typeof ModuleKeySchema>;

const QuestionSchema = z.object({
  id: z.string().uuid(),
  subject: z.enum(['xingce', 'shenlun']),
  module: ModuleKeySchema,
  type: z.enum(['single', 'multiple', 'judge', 'blank', 'essay']),
  stem: z.string().min(1),
  options: z.array(z.string()).optional(),
  answer: z.union([z.string(), z.array(z.string())]),
  // ... 全部显式,无 any
}).openapi('Question');
export type Question = z.infer<typeof QuestionSchema>;

Consequences

  • 正面:any 从类型系统层面被禁止zod 让「不可信的 JSON 输入」在数据层就被收窄为强类型实体,后续代码零断言;前后端共享同一 schema契约一致领域类型、OpenAPI 契约、请求校验三者同源于 zod schema改一处全链跟随杜绝漂移。
  • 负面:引入 zod + zod-openapi 依赖strict 选项与单一契约源会放大生成代码的报错面(但这是隔离性投资,越快暴露越好,避免把 unknown 漏到业务层)。
  • 权衡:以 zod 作为唯一类型与契约来源,不另维护手写 interface 或手写 openapi.yaml消除「type 与 schema 两处对不上」的隐患。
  • ADR-003JSON 数据层——zod 是其关键保障)
  • ADR-006垂直切片 + 单一契约源——zod schema 驱动类型与契约)
  • ADR-001 / ADR-002前后端均全 TS