GraphQL 的类型系统看起来很强,但它的内置标量只有五个:Int、Float、String、Boolean、ID。这意味着「邮箱」「URL」「日期时间」「金额」在默认情况下全都是 String——类型系统对它们毫无约束力。客户端不知道格式要求,服务端要在每个 resolver 里重复校验,一处遗漏就是一个线上事故。
自定义标量(custom scalar) 是解决这个问题的第一层工具:把 String 换成 DateTime,格式校验就下沉到了类型系统。但标量只能校验「格式」,不能校验「业务规则」(如「昵称不能与已有用户重名」),后者需要指令或 resolver 层的校验。本文系统梳理 GraphQL 输入校验的四个层次,讲清自定义标量的三个实现函数、常用标量的取舍与坑、Zod 等校验库的集成方式,以及如何把校验错误归一化成客户端可消费的格式。
一、输入校验的四个层次
1.1 分层模型
GraphQL 的输入校验天然是分层的,每一层负责不同的约束,越靠前的层越早暴露错误:
| 层 | 负责内容 | 实现方式 | 失败时机 |
|---|---|---|---|
| 1. 类型系统 | 类型、非空、枚举、列表结构 | Schema 定义(String!、Int) | 解析期 |
| 2. 标量 | 格式(邮箱、URL、日期、范围) | 自定义标量 | 解析期 |
| 3. 指令 | 长度、正则、数值区间 | @constraint 等 | 校验期 |
| 4. 业务 | 唯一性、状态机、权限 | resolver / 校验库 | 执行期 |
原则:能在类型系统或标量层表达的约束,绝不下放到 resolver。越靠前,错误越早、越一致、越难绕过。
1.2 一个反例
考虑这个常见写法:
type Mutation {
createUser(email: String!, age: Int, birthday: String): User!
}
问题一目了然:email 是不是合法邮箱?age 允许负数吗?birthday 是什么格式(2026-10-07 还是 ISO 时间戳)?类型系统全都答不上来。改进后:
scalar EmailAddress
scalar DateTime
scalar PositiveInt
type Mutation {
createUser(email: EmailAddress!, age: PositiveInt, birthday: DateTime): User!
}
现在这三个约束都由类型系统声明,客户端通过自省就能知道字段的格式要求。
二、自定义标量的三件套
2.1 serialize / parseValue / parseLiteral
一个自定义标量必须实现三个函数,分别对应「出参」「入参变量」「入参字面量」三条路径:
import { GraphQLScalarType, Kind, GraphQLError } from 'graphql';
const DateTimeScalar = new GraphQLScalarType({
name: 'DateTime',
description: 'ISO 8601 日期时间,例如 2026-10-07T19:46:00+08:00',
// 出参:把内部值序列化给客户端
serialize(value) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
throw new GraphQLError('DateTime 无法序列化非法值');
}
return date.toISOString();
},
// 入参(变量):把客户端传入的变量解析成内部值
parseValue(value) {
if (typeof value !== 'string') {
throw new GraphQLError('DateTime 必须是字符串');
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new GraphQLError(`非法的时间格式:${value}`);
}
return date;
},
// 入参(字面量):查询里内联的字面量走这里
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new GraphQLError('DateTime 字面量必须是字符串');
}
return this.parseValue(ast.value);
},
});
三个函数的职责边界是初学者最容易搞混的地方:
| 函数 | 触发场景 | 输入 | 输出 |
|---|---|---|---|
serialize | 返回值发送给客户端 | 服务端内部值 | JSON 可序列化值 |
parseValue | 客户端通过变量传入 | 已解析的 JS 值 | 服务端内部值 |
parseLiteral | 客户端通过内联字面量传入 | AST 节点 | 服务端内部值 |
只实现 parseValue 不实现 parseLiteral 是一个经典 bug:用变量传参时正常,一旦客户端把值内联进查询(birthday: "2026-10-07")就会失败或返回错误值。
2.2 parseLiteral 的 AST 细节
parseLiteral 收到的是 GraphQL 的 AST 节点,ast.kind 决定如何取值:
ast.kind | 对应字面量 | 取值方式 |
|---|---|---|
Kind.STRING | "abc" | ast.value(已去引号) |
Kind.INT | 42 | parseInt(ast.value, 10) |
Kind.FLOAT | 3.14 | parseFloat(ast.value) |
Kind.BOOLEAN | true | ast.value |
Kind.NULL | null | 无值 |
Kind.LIST | [1, 2] | ast.values |
Kind.OBJECT | { a: 1 } | ast.fields |
对于结构复杂的标量(如 JSON),parseLiteral 需要递归处理 LIST 与 OBJECT 节点。这就是为什么推荐用 graphql-scalars 里成熟的 JSON 实现,而不是自己写。
2.3 错误信息的规范化
标量抛出的错误会进入 errors[]。为了让客户端能识别「这是输入校验失败」,应当统一 extensions:
function validationError(message: string, path?: string) {
return new GraphQLError(message, {
extensions: {
code: 'BAD_USER_INPUT',
field: path,
},
});
}
BAD_USER_INPUT 是 GraphQL 社区约定俗成的错误码,客户端可据此区分「用户输入错」(应展示给用户)与「服务端错」(应上报)。错误模型的设计参见 错误处理与可观测性
。
三、常用标量与它们的坑
3.1 用 graphql-scalars 而不是自己造轮子
graphql-scalars 提供了 60+ 个经过实战验证的标量,覆盖了绝大多数需求:
| 标量 | 用途 | 校验内容 |
|---|---|---|
DateTime / Date | 时间 | ISO 8601 格式 |
EmailAddress | 邮箱 | RFC 5322 基本格式 |
URL / IPv4 / IPv6 | 网络地址 | 格式合法性 |
UUID | 唯一标识 | UUID v1~v5 |
JSON / JSONObject | 任意结构 | 任意 JSON 值 |
BigInt | 大整数 | 超出 Int 范围 |
NonNegativeInt / PositiveInt | 有范围整数 | 数值区间 |
NonEmptyString | 非空字符串 | 长度 > 0 |
HexColorCode | 颜色 | #RRGGBB |
Port | 端口 | 0~65535 |
import { DateTimeResolver, EmailAddressResolver, PositiveIntResolver } from 'graphql-scalars';
const resolvers = {
DateTime: DateTimeResolver,
EmailAddress: EmailAddressResolver,
PositiveInt: PositiveIntResolver,
};
3.2 ID 的语义陷阱
ID 在 GraphQL 中会被序列化为字符串,无论服务端返回数字还是字符串。这个设计常被误解:
type User {
id: ID! # 客户端拿到的永远是字符串 "123",不是数字 123
}
三个衍生问题:
- 前端类型定义:若 codegen 把
ID映射为number,运行时就会出错。务必映射为string; - 比较逻辑:
user.id === 123永远是 false,必须user.id === "123"; - 全局唯一性:
ID本身不保证跨类型唯一。若要 Relay 风格的全局 ID,需要编码base64("User:123")并在解码时校验类型。
3.3 DateTime 的时区与精度
DateTime 是踩坑最多的标量,核心问题有三个:
| 问题 | 表现 | 对策 |
|---|---|---|
| 时区 | 服务端存 UTC、客户端显示本地时间,格式不统一 | 传输层统一用 ISO 8601 带时区,显示层转换 |
| 精度 | 毫秒 vs 微秒 vs 纳秒,截断丢失 | 明确精度约定(通常毫秒),文档写清 |
| 无时区时间 | 生日、营业时间这类「本地时间」用 UTC 会偏移 | 用 Date(无时区)而非 DateTime 表示 |
最后一条最容易被忽略:用户的生日 1990-05-20 不该被当作 UTC 时刻存储,否则在东八区显示会变成 1990-05-19。「时刻」(instant)与「日历日期」(calendar date)是两种语义,用两个标量区分。
3.4 Decimal 与金额
Float 是 IEEE 754 双精度浮点,0.1 + 0.2 !== 0.3。金额绝不能用 Float:
scalar Decimal # 用字符串传输,服务端用 Decimal 库运算
import Decimal from 'decimal.js';
const DecimalScalar = new GraphQLScalarType({
name: 'Decimal',
serialize: (v) => new Decimal(v).toFixed(2), // 出参固定两位小数
parseValue: (v) => {
try { return new Decimal(v); }
catch { throw new GraphQLError('非法金额格式'); }
},
parseLiteral: (ast) =>
ast.kind === Kind.STRING || ast.kind === Kind.INT || ast.kind === Kind.FLOAT
? new Decimal(ast.value)
: null,
});
关键点:金额在传输层用字符串,避免 JSON 的浮点表示引入误差;服务端用高精度库运算,最后一步才格式化。
四、标量之外的校验
4.1 为什么标量不够
标量只能校验单个值的格式,无法表达:
- 跨字段约束:
endDate必须晚于startDate; - 条件必填:
type == 'CARD'时cardNumber必填; - 唯一性:邮箱不能已被注册;
- 集合约束:数组长度、元素去重。
这些需要指令或 resolver 层校验。
4.2 指令式校验
长度、正则、数值区间这类「单字段约束」适合用 @constraint 指令声明:
directive @constraint(
minLength: Int
maxLength: Int
pattern: String
min: Int
max: Int
) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
input CreateUserInput {
email: EmailAddress!
nickname: String! @constraint(minLength: 2, maxLength: 20)
bio: String @constraint(maxLength: 200)
}
指令的实现机制(mapSchema + MapperKind)与 Schema 变换一脉相承,Schema 层面的输入对象设计可参考 Schema 设计进阶:接口、联合类型与可空性策略
。
4.3 Zod 集成
在 TypeScript 项目里,Zod 是声明式校验的主流选择。它既可以在 resolver 里手工调用,也可以与 code-first 方案(如 Pothos + @pothos/plugin-zod)深度集成:
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
nickname: z.string().min(2).max(20),
age: z.number().int().min(0).max(150).optional(),
birthday: z.coerce.date().optional(),
});
// resolver 内校验
createUser: async (_, { input }, ctx) => {
const parsed = CreateUserSchema.safeParse(input);
if (!parsed.success) {
throw new GraphQLError('输入校验失败', {
extensions: {
code: 'BAD_USER_INPUT',
issues: parsed.error.issues.map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
},
});
}
return ctx.db.users.create(parsed.data);
}
Zod 的 issues 数组可以逐字段返回错误,让客户端精确高亮出错字段——这比「一个笼统的 400」体验好得多。Zod 的更多用法参见 TypeScript Zod 校验
。
4.4 校验层次的选择
| 约束类型 | 推荐层 | 理由 |
|---|---|---|
| 类型、非空 | 类型系统 | 零成本、编译期可见 |
| 格式(邮箱、URL、日期) | 自定义标量 | 声明式、客户端可见 |
| 长度、正则、区间 | @constraint 指令 | 声明式、集中 |
| 跨字段、条件必填 | Zod / resolver | 需要上下文 |
| 唯一性、状态机 | resolver + 数据库 | 需要查询数据 |
| 外键、唯一索引 | 数据库约束 | 最终兜底 |
五、codegen 与标量映射
5.1 显式映射避免 any
codegen 生成 TypeScript 类型时,自定义标量默认会映射成 any,让类型安全荡然无存。必须在配置里显式映射:
// codegen.ts
const config: CodegenConfig = {
schema: './graphql/**/*.graphql',
generates: {
'./src/generated/graphql.ts': {
plugins: ['typescript', 'typescript-operations', 'typescript-resolvers'],
config: {
scalars: {
DateTime: 'string', // 传输层是 ISO 字符串
Date: 'string',
EmailAddress: 'string',
URL: 'string',
UUID: 'string',
Decimal: 'string', // 金额用字符串传输
JSON: 'Record<string, unknown>',
JSONObject: 'Record<string, unknown>',
BigInt: 'bigint',
},
},
},
},
};
export default config;
5.2 出参类型与入参类型的差异
一个常被忽略的细节:同一个标量在出参与入参位置可能需要不同的 TS 类型。例如 DateTime 在服务端内部是 Date 对象,但客户端拿到的是 ISO 字符串。codegen 生成的类型是面向客户端的,所以映射为 string 是对的;而服务端 resolver 的返回类型由 typescript-resolvers 插件处理,可能仍需要 Date。
处理方式是分别配置 scalars 与 resolverTypeWrapper:
config: {
scalars: { DateTime: 'string' },
resolverTypeWrapper: { DateTime: 'Date | string' },
}
类型安全的完整工作流参见 代码生成与端到端类型安全 。
六、测试标量
6.1 三条路径都要测
标量的 bug 往往只在某一条路径上出现,因此测试必须覆盖三条:
describe('DateTime 标量', () => {
test('serialize:Date → ISO 字符串', () => {
expect(DateTimeScalar.serialize(new Date('2026-10-07T11:46:00Z')))
.toBe('2026-10-07T11:46:00.000Z');
});
test('parseValue:ISO 字符串 → Date(变量路径)', () => {
const d = DateTimeScalar.parseValue('2026-10-07T11:46:00Z');
expect(d).toBeInstanceOf(Date);
});
test('parseLiteral:内联字面量路径', () => {
const ast = { kind: Kind.STRING, value: '2026-10-07T11:46:00Z' };
expect(DateTimeScalar.parseLiteral(ast, null)).toBeInstanceOf(Date);
});
test('非法值抛错', () => {
expect(() => DateTimeScalar.parseValue('not-a-date')).toThrow();
});
});
6.2 边界与畸形输入
标量测试的重点是边界与畸形输入,而不是「正常值能过」:
| 类别 | 用例 |
|---|---|
| 空值 | null、""、[] |
| 类型错误 | 数字传给字符串标量 |
| 边界 | 最大整数、超长字符串、闰年 2 月 29 日 |
| 时区 | 带时区/不带时区/UTC 偏移 |
| 精度 | 小数位、科学计数法 |
| 注入 | 字符串标量里的特殊字符 |
畸形输入的处理必须确定且一致:要么明确抛错,要么明确返回 null,不能有时抛错有时静默。不确定的行为是安全漏洞的温床。
FAQ
Q1:自定义标量能校验业务规则吗?
不能,也不应该。标量只应做格式校验(无状态、无 IO)。业务规则(唯一性、状态机)需要查询数据库或依赖上下文,属于 resolver 层。把业务规则塞进标量会让标量不可复用、难测试,还可能在校验时意外触发 IO。
Q2:ID 到底该用 Int 还是 String?
用 ID,并且在前端把它当作字符串处理。ID 序列化后永远是字符串,若 codegen 映射为 number,运行时比较就会失败。若需要跨类型唯一的全局 ID,用 base64("Type:id") 编码并在解码时校验类型。
Q3:JSON 标量是万能解药吗?
不是。JSON 牺牲了类型系统对结构的约束——客户端不知道里面有什么字段,codegen 也只能生成 Record<string, unknown>。它适合「结构确实动态」的场景(如自定义配置、第三方扩展数据),不应作为「懒得定义类型」的逃避手段。
Q4:Float 什么时候能用?
用于不要求精确的连续量:评分、百分比、坐标、温度。任何涉及金额、计数、需要精确比较的场景都不能用 Float——金额用 Decimal(字符串传输),大整数用 BigInt。
Q5:标量抛错会不会泄漏内部信息?
会,如果错误信息里带了内部实现细节。标量的错误信息应当是面向用户的(「非法的时间格式」),不应包含堆栈或内部字段名。生产环境应统一过滤错误信息的暴露范围,细节参见 错误处理与可观测性 。
小结
GraphQL 的输入校验是分层的:类型系统负责结构与类型,自定义标量负责格式,指令负责单字段约束,resolver 负责业务规则,数据库负责最终兜底。自定义标量的核心是实现好 serialize/parseValue/parseLiteral 三个函数,并保证三条路径的行为一致——只实现前两个是经典 bug。工程上应优先使用 graphql-scalars 里成熟的标量,把 ID 当字符串、把金额用 Decimal 字符串传输、把「时刻」与「日历日期」用不同标量区分。codegen 必须显式映射标量类型,否则类型安全会退化成 any。校验做得越靠前,错误越早暴露、越一致、越难绕过。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。