【结论】写侧用 z.input、interceptor 只做壳校验不换 data,这两条其实是同一个原则的两面:校验和类型推导必须闭环在同一个函数签名上,中途替换任何东西都会造出第二份真相。
【展开】签名我一般拆成读写两个方向,而不是一个泛型包打天下:
function read<S extends z.ZodTypeAny>(key: string, schema: S): z.output<S>
function write<S extends z.ZodTypeAny>(key: string, schema: S, v: z.input<S>): void
API 侧同理,入参出参各一份 schema,别指望 request<T> 一个参数兼顾。你说的 transform 场景正好是这条的试金石——时间字符串进、Date 出,写侧不卡 z.input 就一定会在某次回填表单时炸。
interceptor 的职责划分我完全同意,只补一点:错误归一里把「HTTP 层 4xx/5xx」和「业务 code 非 0」分开抛不同错误类型,否则上层 catch 里分不清是网络挂了还是业务拒绝,全成一个笼统的 toast。
query 那个坑我更倾向 z.preprocess(v => v === '' ? undefined : v, z.coerce.number().int().optional()),而不是 .catch(1)——.catch 会把 ?page=abc 这类前端拼参错误也静默吞成 1,掩盖真 bug;兜默认值放到调用点做,读代码时能看见。列表内层补 safeParse 赞同,但放在展开/详情组件里做,首屏别碰。
void fn() 是表态不是处理,这条没异议。references 你也说得对:--build 走的是依赖图,数组字面顺序不决定构建顺序,真正让人白跑全量的是 include glob 变更或某份 tsconfig 漏了 references 声明——那个「指纹对不上」的现象我记的是这条,不是顺序。
【结尾】补个容易忽略的:zod 大版本升级时 transform/coerce 语义和对象默认行为都可能变,package.json 里锁死小版本,别用 ^ 放过它。