TypeScript 综合项目:可靠的配置加载器与类型安全客户端

现在可以把前面的语法放进一个小项目了:接收外部数据,检查格式,再调用异步逻辑并给出结果。先把这一条路径做通,然后故意传错数据、取消请求、制造失败。这样的练习能看出你是否真正理解类型与运行时的分工。

练习中,外部输入先作为 unknown 接收;检查后再交给业务逻辑。不同结果用可辨识联合表达,共通处理使用泛型,异步操作则补上取消和错误处理。最后通过测试与构建验证,看看这些语法能否在同一个项目里配合。

1. 目标

实现一个小型 API 客户端:

  • 从环境变量加载并验证配置。
  • 请求 /users/:id,对 HTTP 与数据错误分类。
  • 支持超时和调用方取消。
  • 不把 any 暴露到领域层。
  • 有运行测试、类型检查和构建门禁。

2. 领域模型与解析器

TypeScript
export type User = { readonly id: string readonly name: string readonly roles: readonly string[] } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null } export function parseUser(input: unknown): User { if (!isRecord(input)) throw new TypeError("user must be an object") if (typeof input.id !== "string") throw new TypeError("user.id must be string") if (typeof input.name !== "string") throw new TypeError("user.name must be string") if (!Array.isArray(input.roles) || !Array.from(input.roles).every((x) => typeof x === "string")) { throw new TypeError("user.roles must be string[]") } return { id: input.id, name: input.name, roles: [...input.roles] } }

3. 配置解析

TypeScript
export type Config = { readonly baseUrl: URL readonly timeoutMs: number } export function loadConfig(env: Readonly<Record<string, string | undefined>>): Config { const rawUrl = env.API_BASE_URL if (rawUrl === undefined) throw new Error("API_BASE_URL is required") const baseUrl = new URL(rawUrl) if (!['http:', 'https:'].includes(baseUrl.protocol) || baseUrl.username || baseUrl.password || baseUrl.pathname !== '/' || baseUrl.search || baseUrl.hash) { throw new Error("API_BASE_URL must be an HTTP(S) origin without credentials") } const timeoutMs = Number(env.API_TIMEOUT_MS ?? "5000") if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { throw new Error("API_TIMEOUT_MS must be a positive integer") } return { baseUrl, timeoutMs } }

把 env 作为参数而非直接读取全局,使测试可控。

4. 错误模型

TypeScript
export type ClientError = | { kind: "http"; status: number; body: string } | { kind: "invalid-data"; cause: Error } | { kind: "network"; cause: unknown } | { kind: "cancelled"; cause: unknown } export type Result<T> = | { ok: true; value: T } | { ok: false; error: ClientError }

5. 泛型请求执行器

TypeScript
export async function getJson<T>( url: URL, parse: (input: unknown) => T, signal: AbortSignal, ): Promise<Result<T>> { let response: Response let body: string try { response = await fetch(url, { signal }) // Reading the stream can fail after response headers arrive. body = await response.text() } catch (cause) { return { ok: false, error: { kind: signal.aborted ? "cancelled" : "network", cause } } } if (!response.ok) { return { ok: false, error: { kind: "http", status: response.status, body } } } try { return { ok: true, value: parse(JSON.parse(body) as unknown) } } catch (cause) { return { ok: false, error: { kind: "invalid-data", cause: cause instanceof Error ? cause : new Error(String(cause)), }, } } }

泛型参数 T 与解析器输出关联;执行器本身不声称 JSON 天然是 T。

6. 客户端封装

TypeScript
export class UserClient { constructor(private readonly config: Config) {} async find(id: string, callerSignal?: AbortSignal): Promise<Result<User>> { if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) { return { ok: false, error: { kind: "invalid-data", cause: new TypeError("invalid user id") } } } const timeoutSignal = AbortSignal.timeout(this.config.timeoutMs) const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal const url = new URL(`/users/${encodeURIComponent(id)}`, this.config.baseUrl) return getJson(url, parseUser, signal) } }

AbortSignal.timeout/any 需要目标运行时支持;若最低版本不支持,应提供已测试的组合辅助函数或 polyfill,而不是只靠 lib 声明。

7. 测试重点

TypeScript
import { describe, expect, it } from "vitest" describe("parseUser", () => { it("returns an isolated validated value", () => { const raw = { id: "u-1", name: "Ada", roles: ["admin"] } const user = parseUser(raw) raw.roles.push("editor") expect(user.roles).toEqual(["admin"]) }) it("rejects invalid roles", () => { expect(() => parseUser({ id: "u-1", name: "Ada", roles: [1] })).toThrow() }) })

集成测试还应覆盖 2xx 非 JSON、4xx/5xx、连接失败、超时、调用方取消和并发请求。

8. 完成标准

  • npm ci && npm run typecheck && npm test && npm run build 通过。
  • 源码没有未说明的 any 或双重断言。
  • 所有外部数据从 unknown 解析。
  • 错误联合在调用方被穷尽处理。
  • 模块配置与真实 Node/构建器一致。
  • 最低运行时版本对 fetch、AbortSignal.timeout/any 有验证。
  • 生成 JavaScript 与声明文件已人工抽查。

9. 进一步扩展

加入重试时只对明确的瞬时错误重试,采用指数退避与抖动,并保证请求幂等;加入缓存时把 key、TTL、并发请求合并和失败缓存策略写成显式契约。这些是运行时可靠性问题,类型只能帮助表达状态,不能自动实现策略。

10. 为什么把读响应与解析分开

fetch 返回响应头后,读取 body 仍可能断流或被取消。原来把 response.json() 和领域解析一起捕获,会将传输失败误归为 invalid-data。现在先读取文本,再做 JSON 与领域解析,区分 network/cancelled、http 和 invalid-data。cancelled 表示捕获时信号已取消;超时也通过取消信号进入该分支,若要区分原因,再检查 signal.reason。

API_BASE_URL 在本例中只接受 origin,例如 https://api.example.com/。new URL 中以 / 开头的路径会从根目录解析,允许 /v1/ 前缀却不说明它会丢失,会误导读者。需要路径前缀时应重新定义拼接契约并测试。timeoutMs 的上限也为本示例明确收紧,避免把超大数值交给宿主计时器后产生不符合预期的延时。

这个执行器把响应读入内存,适合已知较小的教学响应。生产还要限制响应大小,避免把上游敏感错误正文直接展示或写入日志,并约束可访问的上游地址;仅限制 HTTP(S) 协议不等于完整 SSRF 防护。

参考:MDN Response.json 的异常、MDN URL 解析、MDN AbortSignal。

本例用户 ID 约定为 1~64 个 ASCII 字母、数字、下划线或连字符。先验证 ID,再编码到路径中,避免空 ID 或 .、.. 被 URL 解析器当作路径语义。若真实 API 允许其他字符,应按它的契约调整并测试,不能把本例限制当成通用用户 ID 标准。