JSON 转 TypeScript:从 API 响应生成类型
如果你曾在 TypeScript 中调用 REST API 并将响应类型设为 any,你就知道随之而来的痛苦。自动补全消失了。拼写错误悄悄溜进来。一个你以为是 string 的字段结果是 null,你的应用在生产环境中崩溃了。
解决方案?将你的 JSON 响应转换为正确的 TypeScript 类型。在本指南中,我们将逐步介绍手动映射、自动化工具以及使你的 API 集成坚如磐石的最佳实践。
为什么 JSON 的 TypeScript 类型很重要
TypeScript 的类型系统存在的意义是在错误到达用户之前捕获它们。当你用精确的类型建模 API 响应时,会发生三件事:
- 自动补全无处不在。 你的编辑器知道数据的形状,所以你不再需要猜测字段名。
- 重构变得安全。 重命名一个属性,编译器会告诉你每个出问题的地方。
- 运行时错误减少。 你在边界处理
null、undefined和意外形状,而不是在应用的深处。
没有类型,你本质上是在写带有额外步骤的 JavaScript。有了类型,你就得到了代码和它消费的数据之间的活的契约。
手动 JSON 到 TypeScript 映射
让我们从基础开始。给定一个 JSON 对象,如何将它转换为 TypeScript 接口?
基本对象
这是一个来自 API 的简单用户响应:
{
"id": 42,
"username": "jdoe",
"email": "jdoe@example.com",
"isActive": true
}
对应的 TypeScript 接口:
interface User {
id: number;
username: string;
email: string;
isActive: boolean;
}
每个 JSON 值映射到一个 TypeScript 原始类型:number、string 或 boolean。对象键变成属性名。
嵌套对象
真实的 API 很少返回扁平结构。考虑一个带有地址的用户:
{
"id": 42,
"username": "jdoe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"zipCode": "62704",
"country": "US"
}
}
将嵌套对象拆分为独立的接口:
interface Address {
street: string;
city: string;
zipCode: string;
country: string;
}
interface User {
id: number;
username: string;
address: Address;
}
这使每个类型保持专注和可复用。如果另一个端点也返回 Address,你可以使用同一个接口。
数组
当字段包含数组时,标注元素类型:
{
"id": 42,
"username": "jdoe",
"roles": ["admin", "editor"],
"posts": [
{ "id": 1, "title": "Hello World" },
{ "id": 2, "title": "TypeScript Tips" }
]
}
interface Post {
id: number;
title: string;
}
interface User {
id: number;
username: string;
roles: string[];
posts: Post[];
}
简单情况使用 Type[] 语法。对于复杂泛型,Array<Type> 可能更清晰,但一致性比你选择哪种更重要。
可选字段
API 经常返回可能存在也可能不存在的字段。用 ? 标记它们:
// 有时 API 返回 bio,有时不返回
{
"id": 42,
"username": "jdoe",
"bio": "Software developer"
}
interface User {
id: number;
username: string;
bio?: string; // 可能在响应中不存在
}
可选字段(bio?: string)意味着键可能完全不存在。这与可空字段不同,可空字段的键存在但值可能是 null。
处理边缘情况
生产 API 是混乱的。以下是如何处理常见问题。
可空字段
当字段可以显式为 null 时:
{
"id": 42,
"username": "jdoe",
"avatarUrl": null,
"deletedAt": null
}
interface User {
id: number;
username: string;
avatarUrl: string | null;
deletedAt: string | null; // ISO 日期字符串或 null
}
如果字段既可以不存在又可以为 null,则将两者结合:
interface User {
id: number;
username: string;
middleName?: string | null; // 可能不存在,或存在但为 null
}
联合类型
某些字段接受多种类型。搜索 API 可能返回混合结果:
{
"results": [
{ "type": "user", "id": 1, "username": "jdoe" },
{ "type": "post", "id": 2, "title": "Hello" }
]
}
用可辨识联合进行建模:
interface UserResult {
type: 'user';
id: number;
username: string;
}
interface PostResult {
type: 'post';
id: number;
title: string;
}
type SearchResult = UserResult | PostResult;
interface SearchResponse {
results: SearchResult[];
}
type 字段充当辨别器。TypeScript 在你检查它时自动缩窄类型:
function renderResult(result: SearchResult) {
if (result.type === 'user') {
console.log(result.username); // TypeScript 知道这是 UserResult
} else {
console.log(result.title); // TypeScript 知道这是 PostResult
}
}
从字符串值创建枚举
当 API 返回固定的字符串值集合时,使用字符串字面量联合而不是 TypeScript 枚举:
{ "status": "active" }
// 可能的值:"active"、"inactive"、"suspended"
type UserStatus = 'active' | 'inactive' | 'suspended';
interface User {
id: number;
status: UserStatus;
}
字符串字面量联合更简单、可被 tree-shaking、不像 enum 那样生成运行时 JavaScript。仅在需要反向映射(数字到名称)时才使用 enum。
接口 vs 类型别名
interface 和 type 都可以描述对象形状。以下是何时选择哪个:
对可能扩展的对象形状使用 interface:
interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
interface User extends BaseEntity {
username: string;
email: string;
}
对联合、交叉和计算类型使用 type:
type ApiResponse<T> = {
data: T;
meta: { total: number; page: number };
};
type UserOrAdmin = User | Admin;
实际上,大多数情况下两者都可以。为你的项目选择一个约定并坚持下去。许多团队对数据模型使用 interface,对其他一切使用 type。
使用自动化工具
手动输入接口对小型 API 有效,但当你处理返回 50 多个字段或深度嵌套结构的端点时就变得繁琐了。
在将 JSON 转换为类型之前,确保你的 JSON 是有效且格式良好的。我们的 JSON 格式化工具 可以清理混乱的 API 响应,JSON 验证器 可以在浪费你时间之前捕获语法错误。
在线转换器
quicktype、json2ts 等 JSON 转 TypeScript 转换器工具让你粘贴 JSON 有效负载并立即获得接口。它们自动处理嵌套对象、数组和可选字段。
典型工作流程:
- 调用你的 API 并复制响应
- 将 JSON 粘贴到转换器中
- 审查生成的类型并调整命名
- 将 TypeScript 复制到你的代码库
从 OpenAPI 规范生成代码
如果你的 API 有 OpenAPI(Swagger)规范,完全跳过 JSON 转换。openapi-typescript 等工具直接从规范生成类型:
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.ts
这种方法更优越,因为规范明确定义了可选字段、可空类型和枚举。你无需从示例数据猜测就能获得准确的类型。
使用 Zod 进行运行时验证
为了最大安全性,使用 Zod 定义形状并从中推断 TypeScript 类型:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
bio: z.string().nullable().optional(),
});
type User = z.infer<typeof UserSchema>;
// 现在在运行时验证
const response = await fetch('/api/users/42');
const data = await response.json();
const user = UserSchema.parse(data); // 如果形状不匹配则抛出错误
使用 Zod,你的类型和验证逻辑在同一个地方。如果 API 改变了形状,parse() 会立即抛出错误,而不是让坏数据传播到你的应用中。
将类型与 API 调用集成
类型只有在你将它们应用于数据进入应用的边界时才有用。
类型化的 fetch 包装器
创建一个强制执行类型的通用 fetch 函数:
async function fetchJson<T>(url: string, schema?: z.ZodType<T>): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (schema) {
return schema.parse(data);
}
return data as T;
}
// 用法
const user = await fetchJson<User>('/api/users/42');
const validatedUser = await fetchJson('/api/users/42', UserSchema);
Axios 与类型
如果使用 Axios,将类型应用于响应泛型:
import axios from 'axios';
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
perPage: number;
lastPage: number;
};
}
async function getUsers(page: number = 1) {
const response = await axios.get<PaginatedResponse<User>>('/api/users', {
params: { page },
});
return response.data; // 完全类型化
}
React Query / TanStack Query
在 React 应用中,将类型与数据获取库配对:
import { useQuery } from '@tanstack/react-query';
function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchJson<User>(`/api/users/${id}`),
});
}
// 在你的组件中
const { data: user, isLoading } = useUser(42);
// user 的类型是 User | undefined
最佳实践
启用严格 null 检查
在你的 tsconfig.json 中,启用 strict 模式(至少启用 strictNullChecks):
{
"compilerOptions": {
"strict": true
}
}
没有严格 null 检查,TypeScript 允许你在可能为 null 的值上访问 .username 而不报错。这违背了为 API 响应添加类型的目的。
对不可变数据使用 readonly
API 响应是你接收的数据,而不是你应该修改的数据。将属性标记为 readonly:
interface User {
readonly id: number;
readonly username: string;
readonly email: string;
readonly createdAt: string;
}
或对整个接口使用工具类型:
type ImmutableUser = Readonly<User>;
这可以防止导致细微 bug 的意外变更。
ID 的品牌类型
使用品牌类型防止混淆不同实体的 ID:
type UserId = number & { readonly __brand: 'UserId' };
type PostId = number & { readonly __brand: 'PostId' };
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = 42 as UserId;
const postId = 7 as PostId;
getUser(userId); // OK
getUser(postId); // 类型错误!不能将 PostId 传递到期望 UserId 的地方
集中管理类型
将所有 API 相关类型保存在专用目录中:
src/
types/
api/
user.ts
post.ts
common.ts // 共享类型如 PaginatedResponse
index.ts // 重新导出
这使类型易于查找、导入和维护。当 API 更改时,你更新一个文件,编译器会显示所有破坏的地方。
在边界处验证
永远不要信任外部数据。即使有完美的类型,API 也可能返回意外的内容。在响应进入应用的地方验证它们:
// 不要这样做
const user = (await response.json()) as User; // 盲目信任
// 这样做
const data = await response.json();
const user = UserSchema.parse(data); // 验证形状
类型断言(as User)让编译器安静,但不检查数据。运行时验证(Zod、io-ts、Valibot)才真正验证形状是否匹配。
处理复杂的 JSON 结构
处理大型或复杂的 JSON 有效负载时,在编写类型之前使用 JSON 编辑器 来探索和理解结构。在合适的编辑器中查看嵌套关系比在纯文本中容易得多。
对于使用 JSON API 的团队,请查看以下相关指南:
- JSON 格式化最佳实践 — 保持你的 JSON 干净可读
- JSON Schema 验证指南 — 使用 JSON Schema 验证 API 契约
- JSON API 设计模式 — 设计易于添加类型的 API
- 如何验证 JSON — 尽早捕获格式错误的有效负载
整合所有内容
这是一个完整的示例,将所有内容联系在一起——从原始 JSON 到 React 组件中经过验证的类型化数据:
import { z } from 'zod';
// 1. 定义模式(单一事实来源)
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
role: z.enum(['admin', 'editor', 'viewer']),
profile: z.object({
bio: z.string().nullable(),
avatarUrl: z.string().url().nullable(),
socialLinks: z.array(z.object({
platform: z.string(),
url: z.string().url(),
})).optional(),
}),
});
// 2. 从模式推断类型
type User = z.infer<typeof UserSchema>;
// 3. 创建类型化的 fetch 函数
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`获取用户 ${id} 失败`);
const data = await response.json();
return UserSchema.parse(data);
}
// 4. 在应用中以完全类型安全的方式使用
const user = await getUser(42);
console.log(user.profile.bio); // string | null — 编译器知道
console.log(user.profile.socialLinks); // 数组或 undefined — 编译器知道
没有 any。没有猜测。没有运行时意外。
结论
将 JSON 转换为 TypeScript 类型不仅仅是为了让编译器满意。它是为了构建一个安全网,尽早捕获错误、增强编辑器的自动补全,并使你的代码库更易于维护。
从简单开始:复制一个 JSON 响应,手动编写接口,并为你的 fetch 调用添加类型。随着项目的增长,采用 Zod 进行运行时验证,使用 OpenAPI 代码生成处理大型 API。当类型错误第一次捕获到一个本会到达生产环境的 bug 时,前期投资就会得到回报。