外观
类型约定
在项目根目录下的 typings 目录中定义的类型可以在前后端项目里复用。
大多数情况下,像下面这样定义好全局类型后,在前后端项目里无需引用即可直接使用。
类型分类
项目自定义类型
这类文件拓展名以 .d.ts 结尾。
比如 typings/utils.d.ts 文件
typescript
type TReturn<T = undefined, E = undefined> = [Error, E] | [null, T];
type Binary = 0 | 1;
type MayBe<T> = T | undefined;
type StringType<T extends string | number | bigint | boolean> = `${T}`;
interface CreatedAndUpdatedAt {
created_at: string;
updated_at: string;
}
type Option<T = string, D = {}> = D & {
value: T;
label: string;
};
type JSONString<T = any> = string & { __brand: "JSONString"; __type: T };
// 下划线转小驼峰工具类型(递归处理多段下划线,如 user_info_detail → userInfoDetail)
type CamelCase<S extends string> = S extends `${infer Prefix}_${infer Suffix}${infer Rest}`
? `${Prefix}${Uppercase<Suffix>}${CamelCase<Rest>}`
: S;
// 通用工具类型:将接口/对象的所有键名从下划线转为小驼峰,值类型保持不变
type CamelCaseKeys<T> = {
[K in keyof T as CamelCase<string & K>]: T[K];
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
比如 typings/todo.d.ts 文件
typescript
// 类型:todo-短期目标,goal-长期目标(保留用于兼容旧数据)
type TodoType = "todo" | "goal";
// 紧急程度:0-不紧急,1-紧急
type TodoUrgent = 0 | 1;
// 重要程度:0-不重要,1-重要
type TodoImportant = 0 | 1;
// 状态:0-未完成,1-已完成,2-处理中
type TodoStatus = 0 | 1 | 2;
interface Todo extends CreatedAndUpdatedAt {
id: number;
status: TodoStatus;
type: TodoType;
urgent: TodoUrgent;
important: TodoImportant;
title: string;
content: string;
order: number;
deleted: boolean;
}
type AddingTodo = Omit<Todo, "id" | "created_at" | "updated_at" | "deleted">;
type EditingTodo = AddingTodo & Pick<Todo, "id">;
type TodoDisplayMode = "kanban" | "list" | "matrix" | "todo" | "recycle-bin";
interface ParamsApiGetTodoList extends ParamsQueryPageCommon {
status: StringType<TodoStatus> | "";
urgent: StringType<TodoUrgent> | "";
important: StringType<TodoImportant> | "";
keyword: string;
}
type ReturnApiGetTodoList = PageData<Todo>;
interface ParamsApiUpdateTodoStatus {
id: number;
status: TodoStatus;
order?: number;
urgent?: TodoUrgent;
important?: TodoImportant;
}
type ReturnApiUpdateTodoStatus = Todo;
interface ParamsApiDeleteTodoItem {
id: number;
}
type ParamsApiAddTodoItem = Partial<Pick<Todo, "urgent" | "important">> &
Pick<Todo, "title" | "content" | "status"> &
Partial<Pick<Todo, "type">>;
type ParamsApiUpdateTodoItem = Partial<Pick<Todo, "urgent" | "important">> &
Pick<Todo, "id" | "title" | "content" | "status"> &
Partial<Pick<Todo, "type">>;
type ReturnApiUpdateTodoItem = Todo;
interface ParamsDeleteTodo {
id: number;
}
type ReturnDeleteTodo = true;
interface BodyBatchDeleteTodoItems {
ids: number[];
}
interface BodyBatchUpdateTodoStatus {
ids: number[];
status: TodoStatus;
}
interface BodyBatchRestoreTodoItems {
ids: number[];
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
完善第三方类型
这类文件拓展名以 .global.d.ts 结尾。
使用 declare 关键词来完善第三方现有类型。
比如 typings/express.global.d.ts 文件
typescript
import "express-session";
// #region declareExpress
declare global {
namespace Express {
interface Request {
user?: UserInfo;
realIp?: string;
}
interface Response {
// #region declareExpressResponseSuccess
/**
* 成功响应方法
* @param data - 响应数据
* @param message - 响应消息,默认为'Operation successful'
* @param statusCode - HTTP状态码,默认为200
*/
success<T = unknown>(data: T, message?: string, statusCode?: number): void;
// #endregion declareExpressResponseSuccess
// #region declareExpressResponseSuccessPage
/**
* 成功响应分页数据
* @param data - 响应数据
* @param message - 响应消息,默认为'Operation successful'
* @param statusCode - HTTP状态码,默认为200
*/
successPage<T = unknown>(
data: ParamsPageData<T>,
message?: string,
statusCode?: number,
): void;
// #endregion declareExpressResponseSuccessPage
// #region declareExpressResponseFail
/**
* 错误响应方法(默认发送邮件告警)
* @param message - 错误消息,默认为'An error occurred'
* @param statusCode - HTTP状态码,默认为500
* @param alert - 告警配置,可禁用告警或自定义主题
*/
fail(message: string, statusCode?: number, alert?: ParamsResFailEmailAlert): void;
// #endregion declareExpressResponseFail
}
}
}
// #endregion declareExpress
// 扩展 express-session 的 SessionData 接口
declare module "express-session" {
interface SessionData {
/**
* 验证码
*/
captcha?: string;
userId?: number;
loginTime?: string;
userAgent?: string;
ip?: string;
}
}
export {};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
比如 typings/node.global.d.ts 文件
typescript
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV?: "development" | "production";
MODE?: "int" | "pre" | "production";
// 添加其他环境变量
[key: string]: string | undefined;
}
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
Zod 类型校验
TypeScript 是编译时类型,打包后输出的 JavaScript 代码里是没有 TypeScript 类型的。这会导致一些问题:
- 和外部约定好数据类型后,实际上线后对方未按约定格式返回数据时,程序可能会异常。
- 爬虫数据这类动态数据类型不稳定,随时可能会变,无法固定用一种 TypeScript 类型来约定。
- 字符串长度、邮箱、日期等难以用 TypeScript 来进行具体的限制。
Zod 是运行时校验,可以弥补这个缺陷。
typescript
// 1. 定义运行时校验规则(我们约定以小写 `s` 开头,`s` 表示 `Schema`)
export const sUser = zod.object({
// zod.coerce 会自动进行类型转换,比如字符串类型的 `"1"`,也会符合下面的要求
id: zod.coerce.number().int().positive(),
name: zod.string().optional(),
email: z.string().email(),
age: z.number().min(18).max(60),
});
// 不合法直接抛出清晰错误信息,注意这里不能写成 `const user: zod.infer<typeof sUser> = sUser.parse(rawData);`
const user: User = sUser.parse(rawData);1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
可复用的 src/utils/ZodUtils.mts
typescript
import * as zod from "zod";
/**
* 用于分页的辅助 Zod Schema
*
* 说明:
* 因为分页查询多为 get 请求,实际传递的查询参数是字符串类型的,
* 所以需要使用 `zod.coerce.number()` 处理
*/
export const zodPageQueryCommon = {
pageNo: zod.coerce.number().int().positive("pageNo 必须为正整数"),
pageSize: zod.coerce.number().int().positive("pageSize 必须为正整数"),
};
export const zodSSHConfig = zod
.object({
host: zod.string().describe("服务器地址"),
port: zod.number().describe("服务器端口"),
username: zod.string().describe("用户名"),
password: zod.string().describe("密码"),
})
.describe("SSH配置");1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Zod Schema 命名约定
我们约定以小写字母 s 开头,s 表示 Schema。比如变量名可以叫 sUser,而非 UserSchema.
typescript
// 定义验证 Schema
export const sCreateUser = zod.object({
username: zod.string().min(3),
email: zod.string().email(),
});
// 在路由中使用
router.post(
"/users",
bodyValidateMiddleware({ body: sCreateUser }),
cCreateUser,
);1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
避免使用 zod.infer
不要使用 zod.infer<typeof sXXX> 类型推导,应该使用 typings 中定义的类型。这种写法可以同时校验通过 zod 定义的 schema 与 typescript 定义的类型是否一致。
typescript
const body: ParamsApiAddArticle = sAddArticle.parse(req.body);1
GET 请求中数字类型的特殊处理
在 GET 请求中,查询参数(query parameters)都是字符串类型。对于需要数字类型的参数,应使用 zod.coerce.number() 自动转换并验证,而不是先定义为字符串再手动转换。
正确做法
typescript
// ✅ 使用 zod.coerce.number() 自动转换
export const sGetProductInfo = zod.object({
id: zod.coerce.number().int().positive("产品ID必须为正整数"),
});
export const cGetProductInfo: ExpressRequestHandler = async (req, res) => {
const data = sGetProductInfo.parse(req.query);
const { id } = data; // id 已经是 number 类型,无需手动转换
// ...
};1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
错误做法
typescript
// ❌ 不要先定义为字符串再手动转换
export const sGetProductInfo = zod.object({
id: zod.string(),
});
export const cGetProductInfo: ExpressRequestHandler = async (req, res) => {
const data = sGetProductInfo.parse(req.query);
const id = parseInt(data.id); // 不推荐:需要手动转换
// ...
};1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
boolean 类型字段处理规范
重要:API 中的布尔值字段必须使用 0/1 而不是 true/false!
由于数据库实际存储为整数 0/1,为保持一致性,API 层也应使用 0/1:
typescript
// ✅ 正确:使用 0/1
export const sUpdateUser = zod.object({
is_active: zod.union([zod.literal(0), zod.literal(1)]).optional(),
is_verified: zod.union([zod.literal(0), zod.literal(1)]).optional(),
});1
2
3
4
5
2
3
4
5
typescript
// ✅ 正确:GET 请求参数也使用 0/1
export const sGetUserList = zod.object({
is_active: zod.union([zod.literal("0"), zod.literal("1")]).optional(),
});
// 在控制器中转换
export const cGetUserList: ExpressRequestHandler = async (req, res) => {
const query = sGetUserList.parse(req.query);
const is_active =
typeof query.is_active !== "undefined"
? (Number(query.is_active) as Binary)
: 1;
// ...
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
为什么这样做:
- 类型一致性:数据库、类型定义、API 三层保持统一
- 避免混淆:AI 不会误以为实际存储的是
true/false - 减少转换:减少不必要的类型转换代码
类型命名规范
- API 接口方法的参数类型:使用
ParamsApi*前缀,如ParamsApiGetArticleInfo、ParamsApiAddArticle - API 接口方法的返回类型:使用
ReturnApi*前缀,如ReturnApiGetArticleInfo、ReturnApiAddArticle - 列表项类型:使用
*ListItem后缀,如ArticleListItem、ProductListItem
类型复用
应尽量跨前后端复用类型声明,以最大化 TypeScript 全栈项目的优势。
接口定义是前后端复用类型的典型场景
前端代码里的定义的请求的入参类型和返回值类型,与后端代码中对应 controller 里的入参类型和返回值类型是强对应的,这里不应该重复定义两套类型,而是应该只定义一套类型然后前后端复用。