前后端类型对齐设计
这几天在写一个项目的前后端部分时,因为之前没定义好类型后面写得很痛苦,现在整理一下自己重构后采用的一些设计。
1. 后端类型定义
Service 类型定义
Service 只负责定义自己需要的 DTO,如:
interface FileUploadDTO {
userId: string;
folderId: string;
fileBuffer: Buffer;
fileSize: number;
mimeType: string;
originalName: string;
hash: string;
}
interface DownloadLinkDTO {
userId: string;
fileId: string;
}
然后整理一下需要返回的内容,可以定义一些新的接口来存要返回的东西,比如我在处理文件返回的时候定义的针对前端的脱敏数据结构:
export interface IFilePublic {
id: string;
name: string;
originalName: string;
extension: string;
mimeType: string;
size: number;
folder: string;
user: IUserBasic;
isStarred: boolean;
isTrashed: boolean;
trashedAt?: Date;
isPublic: boolean;
sharedWith: IShareInfo[];
createdAt: Date;
updatedAt: Date;
}
Response 类型定义
这一部分是我主要重构的地方,刚开始我并没有写这个东西、而是直接返回下面的 json:
res.status(StatusCodes.CREATED).json({
success: true,
message: "Registration successful",
data: { user, token },
});
这有一个问题:首先手写 StatusCode 和这些字段还是比较麻烦的,然后后端定义了下面的 ApiResponse:
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
}
如果直接返回 json 的话,data 字段的处理很容易不统一,比如有的是 data: file,有的又是 data: {file},前端解析很容易出错。于是我做了下面的设计:
-
对每个 API 端口的 Response 进行明确的类型定义,而不是像前面这样自己手动构造 express Request。
-
然后定义一个帮助方法
ResponseHelper,它会根据我们需要的 Response 进行统一处理。
这个 Response 类型定义虽然放在 src/types 目录下,但是它依赖了 service 中定义的前端脱敏数据结构,因为这本来就是后端决定返回给前端的东西。虽然 types 感觉应该不依赖别的东西、包含纯粹的类型定义,但我觉得这些 services 要直接返回的东西直接定义在 services 中更方便一些,就没有全部迁移到 response.types.ts 了。下面是一些定义的 Response:
// ==================== Auth Responses ====================
export interface AuthResponse {
user: IUserPublic;
token: string;
}
// ==================== User Responses ====================
export interface UserResponse {
user: IUserPublic;
}
// ==================== File Responses ====================
export interface FileUploadResponse {
file: IFilePublic;
}
如果不需要对 service 返回的内容做任何修饰的话,直接
extends即可:
export interface FolderContentResponse extends IFolderContent {}
然后 ResponseHelper 接收这些东西、把它处理成最终的 express Response:
import { Response } from "express";
import { ApiResponse } from "../types/response.types";
import { StatusCodes } from "http-status-codes";
export class ResponseHelper {
static success<T>(
res: Response,
data: T,
statusCode: number = StatusCodes.OK,
message?: string
): Response {
const response: ApiResponse<T> = {
success: true,
data,
...(message && { message }),
};
return res.status(statusCode).json(response);
}
static created<T>(res: Response, data: T, message?: string): Response {
return this.success(res, data, StatusCodes.CREATED, message);
}
static message(
res: Response,
message: string,
statusCode: number = StatusCodes.OK
): Response {
const response: ApiResponse = {
success: true,
message,
};
return res.status(statusCode).json(response);
}
static noContent(res: Response): Response {
return res.status(StatusCodes.NO_CONTENT).send();
}
static ok<T>(res: Response, data: T): Response {
return this.success(res, data, StatusCodes.OK);
}
}
这样我们的后端的 Controller 的返回值就变成下面这样了:
return ResponseHelper.created<AuthResponse>(
res,
{ user, token },
"Registration successful"
);
这个返回就非常统一美观了。
2. 前端类型定义
我们也对前端做类似的封装,把我们的前端 Request 作为泛型类型传给 apiClient 调用,service 只需要传入即可:
get: <T>(url: string): Promise<ApiResponse<T>> => {
return apiClient.get<ApiResponse<T>>(url).then((response) => response.data);
},
post: <T, D>(url: string, data?: D): Promise<ApiResponse<T>> => {
return apiClient
.post<ApiResponse<T>>(url, data)
.then((response) => response.data);
},
然后剩下的就比较好办了,前端直接在 src/types 中把 Request、Response 全部放进去就行了:
export const loginSchema = z.object({
email: z.email("Invalid email format"),
password: z.string().min(1, "Password is required"),
});
export type LoginRequest = z.infer<typeof loginSchema>;
注意看 backend 的 controller 是从前端的哪里,比如对于下面的 controller:
const file = await this.fileService.uploadFile({
userId: req.user.id,
folderId,
fileBuffer: req.file.buffer,
fileSize: req.file.size,
mimeType: req.file.mimetype,
originalName: req.file.originalname,
hash,
});
originalName这个字段是从req.file中解析的,我们前端的 Request 就不需要包含这个东西了。
最终的效果如下:
login: async (req: LoginRequest): Promise<AuthResponse> => {
try {
const response = await api.post<AuthResponse, LoginRequest>(
"/api/auth/login",
req
);
if (response.success && response.data) {
localStorage.setItem("token", response.data.token);
return response.data;
}
throw new Error(response.message || "Login failed");
} catch (error) {
throw new Error(error instanceof Error ? error.message : "Login failed");
}
},
这样前后端类型对齐就比较方便了。
这个方案也是初步的,之后可能还会修正。