标量类型与输入校验

GraphQL 标量类型与输入校验实战:自定义标量的 serialize/parseValue/parseLiteral 三件套、graphql-scalars 常用标量、ID 与 DateTime/Decimal 的坑、标量 vs 指令 vs resolver 的校验分层、Zod 集成、codegen 映射、错误归一化与测试方法。

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.INT42parseInt(ast.value, 10)
Kind.FLOAT3.14parseFloat(ast.value)
Kind.BOOLEANtrueast.value
Kind.NULLnull无值
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。校验做得越靠前,错误越早暴露、越一致、越难绕过。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「GraphQL」更多文章

  1. Mock 与测试策略
  2. 自定义指令与模式扩展
  3. 压测与容量规划