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) || !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. 配置解析
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. 错误模型
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. 泛型请求执行器
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. 客户端封装
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. 测试重点
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 标准。