TypeScript 综合项目:可靠的配置加载器与类型安全客户端
本项目把环境、unknown 解析、可辨识联合、泛型、异步、取消、测试和构建串成完整链路。重点是边界正确,不是堆框架。
1. 目标
实现一个小型 API 客户端:
- 从环境变量加载并验证配置。
- 请求
/users/:id,对 HTTP 与数据错误分类。 - 支持超时和调用方取消。
- 不把
any暴露到领域层。 - 有运行测试、类型检查和构建门禁。
2. 领域模型与解析器
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) || !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. 配置解析
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)
const timeoutMs = Number(env.API_TIMEOUT_MS ?? "5000")
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
throw new Error("API_TIMEOUT_MS must be a positive integer")
}
return { baseUrl, timeoutMs }
}
把 env 作为参数而非直接读取全局,使测试可控。
4. 错误模型
export type ClientError =
| { kind: "http"; status: number; body: string }
| { kind: "invalid-data"; cause: Error }
| { kind: "network"; cause: unknown }
export type Result<T> =
| { ok: true; value: T }
| { ok: false; error: ClientError }
5. 泛型请求执行器
export async function getJson<T>(
url: URL,
parse: (input: unknown) => T,
signal: AbortSignal,
): Promise<Result<T>> {
try {
const response = await fetch(url, { signal })
if (!response.ok) {
return {
ok: false,
error: { kind: "http", status: response.status, body: await response.text() },
}
}
try {
return { ok: true, value: parse(await response.json()) }
} catch (cause) {
return {
ok: false,
error: {
kind: "invalid-data",
cause: cause instanceof Error ? cause : new Error(String(cause)),
},
}
}
} catch (cause) {
return { ok: false, error: { kind: "network", cause } }
}
}
泛型参数 T 与解析器输出关联;执行器本身不声称 JSON 天然是 T。
6. 客户端封装
export class UserClient {
constructor(private readonly config: Config) {}
async find(id: string, callerSignal?: AbortSignal): Promise<Result<User>> {
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. 测试重点
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、并发请求合并和失败缓存策略写成显式契约。这些是运行时可靠性问题,类型只能帮助表达状态,不能自动实现策略。