TypeScript · 异步类型与运行时校验闭环

类型管不了网线那头发来什么: as User 是自欺, schema.parse 才是闭环 — 校验通过即收窄

任意字节 校验通过 外部输入 JSON.parse · 接口 · 表单 环境变量 · localStorage 类型层一律视为 unknown 运行时校验器 zod / valibot / io-ts schema.parse / safeParse 运行时唯一的话语权 可信内部类型 T 从 schema 单源推导 z.infer<typeof Schema> 业务代码里畅通无阻 跳过中间那格 = as User 自欺欺人: 类型说谎, 运行时崩给用户看 // 反面教材: as 伪造类型 const u = (await res.json()) as User; u.profile.name; // 后端把 profile 砍了 → 白屏 // 编译器 0 报错, 监控 0 告警, 用户 1 屏白 // 正解: parse 闭环 const u = UserSchema.parse(await res.json()); u.profile.name; // 校验过了, 字段保证在 // schema 变了 → 后端字段一改, 这里立刻 ZodError // 你要的"数据契约变更告警", 天生自带 // as 只许出现在 parse 内部 — 业务代码摸不到 // 异步类型三板斧 async function load() { return User; } // 返回类型自动包成 Promise<User> type U = Awaited<ReturnType<typeof load>>; // Promise<Promise<T>> 递归解包 → T try { await run(); } catch (e) { e.message; } // e 是 unknown! // 正确姿势: if (e instanceof Error) log(e.message) // Promise.all 保元组类型, map 结果是数组 // AbortSignal 类型要贯穿: fetch(url, { signal }) 全链路

外部即 unknown

  • • 网络/存储/用户输入 = 不可信
  • • 类型系统管不了运行时字节
  • • as User 是给编译器看的谎话
  • • 边界处 parse, 内部才可信

schema 单源

  • • zod schema 是唯一事实
  • • z.infer 出编译期类型
  • • parse 出运行时保证
  • • 一份定义两道防线

异步类型三件

  • • async 返回自动包 Promise<T>
  • • Awaited 递归解包类型
  • • catch 参数是 unknown 要收窄
  • • AbortSignal 类型贯穿取消链

💡 一句话理解

TS 的类型在编译期就已定格, 但网络那头发来的字节在运行时才到 — 两套世界互不通信。as User 只是把你的"愿望"写进编译器, 运行时后端少发一个字段, 照样白屏。闭环的做法是在边界放一道运行时校验器: UserSchema.parse(json) — 过了校验, 数据真的就是 User 形状; 没过, 炸在你能捕获的边界, 而不是炸在渲染深处。

最妙的是单源: schema 用 z.infer 推出编译期类型, 类型和校验永远一致, 后端改契约你第一时间收到 ZodError。再把异步类型的三件套配齐 — Promise<T> 包装、Awaited 解包、catch 的 unknown 收窄 — 异步链路上就没有"漏网之鱼"了。

🧠 必知必会 必考 & 必会

Promise<T> 包装
async 函数返回 T 自动变成 Promise<T>; 调用侧 await 后才拿到 T。
async function me(): Promise<User> { /* ... */ }
const u = await me();   // u: User
Awaited 解包
官方工具递归解开 Promise/thenable 嵌套, 从函数类型反推"最终值"。
type A = Awaited<Promise<Promise<string>>>;
A;   // → string  递归解到头
zod 单源
schema 定义运行时规则, z.infer 推导编译期类型; 类型和校验永不漂移。
const UserS = z.object({ id: z.number(), name: z.string() });
type User = z.infer<typeof UserS>;   // { id: number; name: string }
parse vs safeParse
parse 不合法直接 throw(边界 throw 可控); safeParse 返回判别联合由你分支。
const r = UserS.safeParse(json);
if (!r.success) return r.error.issues;   // err 分支
r.data;   // ok 分支: User
catch 是 unknown
TS 4.4 起 catch 参数默认 unknown — 不能直接 e.message。
try { await api() }
catch (e) {
  if (e instanceof Error) log(e.message);
  else log(String(e));
}
类型守卫复用
轻量场景不引库: 手写 is 守卫也能过闸; 但 schema 支持更细粒度报错。
const isUser = (v: unknown): v is User =>
  typeof v === 'object' && v !== null
  && 'id' in v && 'name' in v;
AbortSignal 贯穿
取消是运行时行为, 类型上要让 signal 参数出现在整条链路签名里。
async function fetchUser(id: string,
  signal?: AbortSignal): Promise<User> {
  return get(`/u/${id}`, { signal });
}
泛型请求封装
端点→响应类型映射 + 边界 parse, 一处封装全仓类型安全。
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
}
元组保留
Promise.all 对元组入参返回元组类型, 各位置类型不丢; map 出的数组才退化成联合数组。
const [u, o] = await Promise.all([getUser(), getOrders()]);
u;   // User  o: Order[]  各是各的
top-level await
模块顶层 await: 初始化即模块, 依赖方自动等待; 注意打包目标支持。
// config.ts (ESM)
export const cfg = ConfigSchema.parse(await loadRemote());
错误通道建模
可预期失败用 Result 联合, 不可预期才 throw; 类型替你盘点分支。
type Res<T> = { ok: true; v: T } | { ok: false; code: ErrCode };
if (!r.ok) return toast(r.code);   // 编译器逼你处理
端到端类型
tRPC/OpenAPI codegen 把"前后端契约"变成共享类型, 边界校验前置到生成层。
// openapi-typescript: 生成类型 + zod 兜底运行时
type Paths = typeof import('./schema')  // 生成物

🏭 生产实战 real world

场景 1 · 全仓 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, 监控告警

场景 2 · 类型安全 fetch 封装: 边界是唯一 as 所在地

封装层持有 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% 验证过

场景 3 · catch unknown 收窄: 错误上报不再丢栈

全局错误处理 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) };  // 兜底但要留痕
}

场景 4 · Result 类型: 支付失败是业务分支不是异常

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) { /* 穷尽处理, 漏一个编译报错 */ }
}

场景 5 · 分页响应泛型: Page<T> 一处定义

每个列表接口手写 {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[] ...

场景 6 · 表单校验: schema 与错误消息同源

前端校验与类型同源, 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 };
}

场景 7 · 竞态防御: AbortSignal 类型贯穿 + 旧响应丢弃

搜索快速输入, 慢响应后到覆盖新结果。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;   // 只允许"最新一泳"的数据落地
}

场景 8 · React Query 泛型: queryFn 返回值即缓存类型

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, 全自动

场景 9 · 上传进度: 事件流类型的判别建模

上传进度事件是 (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);
};

场景 10 · 端到端类型选型: 三条路线的取舍

契约一致性三路线: tRPC(全 TS 栈首选)、OpenAPI codegen(跨语言)、schema 共享包(手动同步):

// tRPC: 路由即类型, 前后端零手写
const u = await trpc.user.byId.query(1);   // 类型直通
// OpenAPI: 后端是别的语言时
//   openapi-typescript 生成类型 + zod 兜运行时
// 共享包: schemas 在 npm 包里, 两端 import 同一份 zod

⚠️ 编码注意与常见坑 pitfalls

坑 1 · as 断言响应 — 类型是愿望不是事实, 缺字段白屏. 正解: 边界 schema.parse。
// 错: const u = (await r.json()) as User;
// 对: const u = UserS.parse(await r.json());
坑 2 · catch 直接 e.message — unknown 上取属性编译错. 正解: instanceof 收窄或 String(e)。
// 错: catch (e) { log(e.message); }
// 对: catch (e) { log(e instanceof Error ? e.message : String(e)); }
坑 3 · Promise.all 全败即弃 — 部分成功数据被整体丢弃. 正解: allSettled + 分类处理。
const rs = await Promise.allSettled([a, b]);
rs.filter(r => r.status === 'rejected').forEach(report);
坑 4 · await 串行丢并行 — 两个独立请求变串行, 尾延迟翻倍. 正解: 同时发起再 await。
// 对: const [u, o] = await Promise.all([getU(), getO()]);
坑 5 · map 返回 Promise 不 await — 数组里全是 pending 的 Promise, all 得到"假结果". 正解: Promise.all(map(...))。
// 错: const rs = ids.map(id => get(id));  // Promise[]
// 对: const rs = await Promise.all(ids.map(get));
坑 6 · is 守卫太宽 — 查一个字段就放行, 数据残缺流入业务. 正解: schema 或全字段守卫。
// 错: (v): v is User => 'id' in v
坑 7 · safeParse 忘判 success — r.data 在失败分支是 undefined. 正解: 判别联合分支里用, 严格模式逼你判。
if (!r.success) return; r.data;   // 分支后才可用
坑 8 · 泛型请求 any 兜底 — fallback 写 any, 封装白做. 正解: 泛型边界也走 unknown+parse。
// 错: return json as T;   // T 是空头支票
// 对: schema 表驱动, 见场景 2
坑 9 · async 返回 Promise 嵌套 — async 里 return await 冗余但无害; 返回 Promise<Promise> 类型靠 Awaited 解. 正解: 接口签名用 Awaited 反推。
type V = Awaited<ReturnType<typeof load>>;
坑 10 · then 里 throw 走错门 — onFulfilled 抛错不会被同节第二参数接住. 正解: 错误统一走 .catch。
// 错: p.then(v => risky(v), e => fix(e));  // risky 错没人接
// 对: p.then(v => risky(v)).catch(e => fix(e));
坑 11 · AbortSignal 不贯穿 — 页面卸载请求照跑, 竞态+浪费. 正解: signal 参数出现在每一层签名。
async function q(sql: string, signal?: AbortSignal) {
  return db.query(sql, { signal });
}
坑 12 · void 返回被当值用 — Promise<void> await 出 undefined. 正解: 需要值就明确返回类型, void 只用于副作用。
const x = await logAndReturnNothing();  // x: void → undefined
坑 13 · 乐观更新类型不回滚 — UI 先改成功态, 失败分支忘恢复. 正解: 回滚逻辑与乐观写入同层成对。
try { setOptimistic(v); await save(); }
catch { setOptimistic(prev); }   // 成对出现
坑 14 · unhandled rejection — 事件回调里的 async 错误无人接. 正解: 回调内 try/catch 自包。
btn.onclick = async () => {
  try { await submit(); } catch (e) { toast(toReport(e)); }
};
坑 15 · allSettled 结果不收窄 — status 判断后仍是联合. 正解: 判别后取 value/reason。
for (const r of rs) {
  if (r.status === 'fulfilled') use(r.value);  // 分支内窄化
}
坑 16 · 事件监听参数 any — addEventListener 回调 e 是宽类型. 正解: 泛型事件映射或 zod 收窄 payload。
ws.addEventListener('message', (ev) => {
  const m = MsgS.parse(JSON.parse(ev.data));  // 收窄到判别联合
});
坑 17 · json() 默认 any 依赖 — lib.dom 的 Response.json() 返回 any. 正解: 封装层返回 unknown, 见场景 2。
async function getJson(u: string): Promise<unknown> {
  return fetch(u).then(r => r.json());
}
坑 18 · Signal 过早 abort — 超时计时忘了 clear, 正常请求被误杀. 正解: finally 清理 timer。
const t = setTimeout(() => ac.abort(), 3000);
try { await fetch(u, { signal }) } finally { clearTimeout(t); }
坑 19 · 条件类型套 Promise 误判 — T extends Promise<infer U> 遇 all 元组/嵌套需 Awaited. 正解: 用官方 Awaited 不自造。
type V = Awaited<typeof someAsyncValue>;
坑 20 · top-level await 目标不支持 — 编译到 es2017/旧打包目标直接构建失败. 正解: 确认 ESM+新目标, 否则退回 async init()。
// tsconfig: target ES2022+ 且输出 ESM 才可用