实战:写一个类型安全的 API 客户端

本节目标

我们用泛型 + 判别联合做一个「类型安全」的 fetch 封装:

// 运行环境:Node.js 18+ + typescript;类型检查 npx tsc --noEmit ts-l15.ts
// 说明:下面代码用浏览器/Node18 的 fetch;运行时直接跑需网络,类型部分不依赖网络

// 统一的响应结构(判别联合:用 ok 字段区分成功/失败)
type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string }

// 通用请求函数:传入「响应数据类型 T」,返回对应 ApiResult<T>
async function getJSON<T>(url: string): Promise<ApiResult<T>> {
  try {
    const res = await fetch(url)
    if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
    const data = (await res.json()) as T // 这里 as 是必要的:网络数据是 unknown
    return { ok: true, data }
  } catch (e) {
    return { ok: false, error: String(e) }
  }
}

// 使用示例:定义接口返回结构
interface User { id: number; name: string }
async function main() {
  const r = await getJSON<User>('https://api.example.com/user/1')
  if (r.ok) {
    console.log(r.data.name) // 这里 data 是 User,有提示
  } else {
    console.error(r.error)    // 这里走错误分支
  }
}

注意:网络返回的是「运行时数据」,TS 只能「猜」成 T(用 as),真正的校验还需 zod 这类运行时库。但至少调用方拿到了整齐的 r.ok ? data : error 分支,不会漏处理错误。

名词解释

判别联合实战(Result 类型):用 { ok: true; data } | { ok: false; error } 这种结构,把「成功/失败」编码进类型。调用方必须 if (r.ok) 才能拿到 data,于是「忘了处理错误」在编译期就不可能发生。这是 Rust 的 Result、Go 的 (值, err) 思想在 TS 里的落地。

泛型封装(Generic Wrapper):把「变化的部分」(这里是响应数据类型 T)抽成泛型参数,于是 getJSON<User>、getJSON<Order> 复用同一套逻辑却各自保留精确类型。这是泛型最经典的实战价值。

课后练习

练习 1:如果 getJSON 不返回判别联合,而直接 return data,会有什么隐患?

答案:调用方拿到的「可能是数据也可能是 undefined/抛错」,TS 无法强制你判空/判错,很容易写出 data.name 在失败时崩的代码。判别联合强制 if (r.ok) 才能用 data,把错误处理变成「编译期义务」。

练习 2:为什么网络 JSON 仍需 as T 或运行时校验,TS 不能直接保证?

答案:因为接口返回的是运行时字符串,TS 编译期不知道它真实结构。TS 只能「按你声明的 T 去理解」,但「声明是否和真实数据一致」需要 zod 等运行时校验来保证。as 是信任声明,zod 是验证声明。

总结

这一节是前面所有知识的「阅兵式」。我的核心观点:TS 在业务里最大的价值,不是「写类型时爽」,而是「逼你把错误的可能性和数据的形状都显式建模出来」。ApiResult<T> 这种判别联合,把「网络可能失败」从「藏在心里的事」变成「编译器盯着你的事」。新手常觉得「包一层 Result 好啰嗦」,但当你的接口从 1 个涨到 100 个、线上事故从「偶发白屏」变成「编译期就拦下」,你会感谢这层啰嗦。顺便强调:as T 只是信任,真要稳请用 zod 在边界做运行时校验——TS 管编译期,zod 管边界,各司其职。