类型管不了网线那头发来什么: as User 是自欺, schema.parse 才是闭环 — 校验通过即收窄
TS 的类型在编译期就已定格, 但网络那头发来的字节在运行时才到 — 两套世界互不通信。as User 只是把你的"愿望"写进编译器, 运行时后端少发一个字段, 照样白屏。闭环的做法是在边界放一道运行时校验器: UserSchema.parse(json) — 过了校验, 数据真的就是 User 形状; 没过, 炸在你能捕获的边界, 而不是炸在渲染深处。
最妙的是单源: schema 用 z.infer 推出编译期类型, 类型和校验永远一致, 后端改契约你第一时间收到 ZodError。再把异步类型的三件套配齐 — Promise<T> 包装、Awaited 解包、catch 的 unknown 收窄 — 异步链路上就没有"漏网之鱼"了。
async function me(): Promise<User> { /* ... */ } const u = await me(); // u: User
type A = Awaited<Promise<Promise<string>>>; A; // → string 递归解到头
const UserS = z.object({ id: z.number(), name: z.string() }); type User = z.infer<typeof UserS>; // { id: number; name: string }
const r = UserS.safeParse(json); if (!r.success) return r.error.issues; // err 分支 r.data; // ok 分支: User
try { await api() } catch (e) { if (e instanceof Error) log(e.message); else log(String(e)); }
const isUser = (v: unknown): v is User => typeof v === 'object' && v !== null && 'id' in v && 'name' in v;
async function fetchUser(id: string, signal?: AbortSignal): Promise<User> { return get(`/u/${id}`, { signal }); }
async function api<P extends keyof Routes>(p: P): Promise<Routes[P]> { const raw = await fetch(base + p).then(r => r.json()); return Routes[P].parse(raw); // 每个 P 配一个 schema }
const [u, o] = await Promise.all([getUser(), getOrders()]); u; // User o: Order[] 各是各的
// config.ts (ESM) export const cfg = ConfigSchema.parse(await loadRemote());
type Res<T> = { ok: true; v: T } | { ok: false; code: ErrCode }; if (!r.ok) return toast(r.code); // 编译器逼你处理
// openapi-typescript: 生成类型 + zod 兜底运行时 type Paths = typeof import('./schema') // 生成物
手写 interface 与后端契约漂移半年无人知。zod schema 单源 + parse 边界:
// schemas/user.ts — 唯一事实来源 export const UserS = z.object({ id: z.number(), name: z.string(), vip: z.boolean(), }); export type User = z.infer<typeof UserS>; // 后端把 vip 改成 status → 所有依赖接口当场 ZodError, 监控告警
封装层持有 schema 表, 业务侧拿到的一定是"验证过的 T":
const schemas = { '/me': UserS, '/orders': z.array(OrderS), } as const; export async function api<P extends keyof typeof schemas>(p: P) { const raw: unknown = await fetch(base + p).then(r => r.json()); return schemas[p].parse(raw); // 唯一的运行时闸门 } const me = await api('/me'); // User, 100% 验证过
全局错误处理 e.message 全是编译错(unknown), 分类收窄后各走各路:
function toReport(e: unknown) { if (e instanceof ApiError) return { kind: e.code, msg: e.message }; if (e instanceof ZodError) return { kind: 'contract', issues: e.issues }; if (e instanceof DOMException) return { kind: 'abort' }; return { kind: 'unknown', msg: String(e) }; // 兜底但要留痕 }
catch 拿不到"余额不足"这种可预期失败。Result 联合让编译器盘点所有分支:
type PayRes = | { ok: true; receipt: string } | { ok: false; reason: 'NO_FUNDS' | 'TIMEOUT' | 'RISK' }; function render(r: PayRes) { if (r.ok) return showReceipt(r.receipt); switch (r.reason) { /* 穷尽处理, 漏一个编译报错 */ } }
每个列表接口手写 {list,total,page} 三十遍。泛型容器 + schema 组合:
const PageS = <T extends z.ZodTypeAny>(item: T) => z.object({ list: z.array(item), total: z.number(), page: z.number() }); const OrdersPage = PageS(OrderS); type OrdersPage = z.infer<typeof OrdersPage>; // list: Order[] ...
前端校验与类型同源, safeParse 的 issues 直接映射回字段:
const FormS = z.object({ email: z.string().email(), age: z.number().min(0).max(120), }); function validate(form: unknown) { const r = FormS.safeParse(form); if (r.success) return { ok: true, data: r.data }; const errs: Record<string, string> = {}; for (const i of r.error.issues) errs[i.path.join('.')] = i.message; return { ok: false as const, errs }; }
搜索快速输入, 慢响应后到覆盖新结果。signal 取消 + 泳道号双保险:
let seq = 0; async function search(q: string): Promise<Item[]> { const my = ++seq; const ac = new AbortController(); const items = ItemsS.parse(await getJson(`/s?q=${q}`, ac.signal)); if (my !== seq) throw new DOMException('stale', 'AbortError'); return items; // 只允许"最新一泳"的数据落地 }
queryKey 与数据类型绑定, useQuery 的 data 自动带类型:
const useUser = (id: number) => useQuery({ queryKey: ['user', id], queryFn: () => api('/me'), // 返回 User(已 parse) }); const { data } = useUser(1); data?.name; // data: User | undefined, 全自动
上传进度事件是 (progress|done|error) 判别联合, 分支内字段齐全:
type UploadEvent = | { type: 'progress'; loaded: number; total: number } | { type: 'done'; url: string } | { type: 'error'; msg: string }; xhr.onprogress = ev => { const e = UploadEventSchema.parse(ev); if (e.type === 'progress') bar(e.loaded / e.total); };
契约一致性三路线: tRPC(全 TS 栈首选)、OpenAPI codegen(跨语言)、schema 共享包(手动同步):
// tRPC: 路由即类型, 前后端零手写 const u = await trpc.user.byId.query(1); // 类型直通 // OpenAPI: 后端是别的语言时 // openapi-typescript 生成类型 + zod 兜运行时 // 共享包: schemas 在 npm 包里, 两端 import 同一份 zod
schema.parse。 // 错: const u = (await r.json()) as User; // 对: const u = UserS.parse(await r.json());
// 错: catch (e) { log(e.message); } // 对: catch (e) { log(e instanceof Error ? e.message : String(e)); }
const rs = await Promise.allSettled([a, b]); rs.filter(r => r.status === 'rejected').forEach(report);
// 对: const [u, o] = await Promise.all([getU(), getO()]);
// 错: const rs = ids.map(id => get(id)); // Promise[] // 对: const rs = await Promise.all(ids.map(get));
// 错: (v): v is User => 'id' in v
if (!r.success) return; r.data; // 分支后才可用
// 错: return json as T; // T 是空头支票 // 对: schema 表驱动, 见场景 2
type V = Awaited<ReturnType<typeof load>>;
// 错: p.then(v => risky(v), e => fix(e)); // risky 错没人接 // 对: p.then(v => risky(v)).catch(e => fix(e));
async function q(sql: string, signal?: AbortSignal) { return db.query(sql, { signal }); }
const x = await logAndReturnNothing(); // x: void → undefined
try { setOptimistic(v); await save(); } catch { setOptimistic(prev); } // 成对出现
btn.onclick = async () => { try { await submit(); } catch (e) { toast(toReport(e)); } };
for (const r of rs) { if (r.status === 'fulfilled') use(r.value); // 分支内窄化 }
ws.addEventListener('message', (ev) => { const m = MsgS.parse(JSON.parse(ev.data)); // 收窄到判别联合 });
async function getJson(u: string): Promise<unknown> { return fetch(u).then(r => r.json()); }
const t = setTimeout(() => ac.abort(), 3000); try { await fetch(u, { signal }) } finally { clearTimeout(t); }
type V = Awaited<typeof someAsyncValue>;
// tsconfig: target ES2022+ 且输出 ESM 才可用