Relay+TypeScript向GraphQL mutation传Int参数被转Float如何解决
Relay + TypeScript 下GraphQL Int类型被序列化为Float的标准解决方案
问题复现场景
Schema First模式下定义的Mutation输入类型如下:
input VerifyBankAccountInput { bankAccountId: ID! amount1Cents: Int! amount2Cents: Int! }
前端采用Relay + TypeScript技术栈,relay-compiler自动生成的graphql.ts文件中,GraphQL Int类型默认映射为TypeScript number类型。传参前已确认两个金额字段均为number类型,但实际GraphQL POST请求中部分值被序列化为Float格式,触发服务端报错,实际传参示例:
{ input: { bankAccountId: "SomeRelayId", amount1Cents: 56.99999999999999, amount2Cents: 61 } }
该问题复现不稳定:开发与生产环境表现有差异,同逻辑处理的两个字段序列化结果也可能不同。
已尝试的不可行方案:
- 引入基于BigInt的自定义Int类型:自动生成的
graphql.ts仍要求参数为number类型,抛出Type 'BigInt' is not assignable to type 'number'类型错误 - 手动修改自动生成的
graphql.ts类型定义:每次执行relay编译命令后修改会被覆盖,使用的编译命令如下:relay-compiler --src ./src --schema ./schema.graphql --language typescript --customScalars.URL=URL --customScalars.LocalDate=LocalDate --customScalars.Instant=string - 传参前手动对数值取整:方案健壮性不足,无法从根源规避问题
核心原因
JavaScript没有原生整数类型,所有number本质都是双精度浮点数,示例中出现的56.99999999999999就是浮点数运算的典型精度丢失结果。GraphQL客户端序列化参数时不会自动判断number是否为整数,只要值存在小数位就会序列化为Float类型,这也是问题表现不稳定的核心原因——只有运算刚好踩中精度丢失边界时才会复现。
标准处理方案
Schema First模式下不需要修改服务端Schema,通过三层配置即可彻底解决问题,所有配置不会被relay-compiler覆盖:
1. 配置Relay自定义标量,从类型层强制整数约束
首先在项目中定义带标记的整数类型(Branded Type),从TS类型层面区分普通number和合法Int:
// src/types/graphql.ts export type Int = number & { readonly __intTag: unique symbol }; /** * 转换数值为合法Int类型,开发阶段直接拦截非整数值 */ export function asInt(value: number): Int { if (!Number.isInteger(value)) { throw new Error(`Expected valid integer, received: ${value}`); } return value as Int; }
修改package.json中的relay-compiler命令,添加Int类型的自定义标量映射,让自动生成的类型文件直接使用定义的Int类型:
relay-compiler --src ./src --schema ./schema.graphql --language typescript --customScalars.Int=src/types/graphql#Int --customScalars.URL=URL --customScalars.LocalDate=LocalDate --customScalars.Instant=string
重新执行编译后,所有GraphQL Int类型的字段都会被映射为自定义的Int类型,非整数值无法直接通过TS类型校验,且该配置会永久生效,不会被编译覆盖。
2. 增加网络层序列化兜底,彻底规避浮点尾差
配置Relay网络层的fetch逻辑,在参数序列化前递归处理所有变量,修正浮点数精度导致的尾差问题,同时做运行时校验:
// src/relay/environment.ts import { Environment, Network, RecordSource, Store } from 'relay-runtime'; const fetchQuery = async (operation: any, variables: Record<string, any>) => { const response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ query: operation.text, variables: sanitizeIntValues(variables), }), }); return response.json(); }; /** * 递归处理变量,修正整数的浮点精度问题,拦截非法非整数值 */ function sanitizeIntValues(value: unknown): unknown { if (typeof value === 'number') { const rounded = Math.round(value); // 判定是否为接近整数的浮点数(精度丢失场景) if (Math.abs(value - rounded) < Number.EPSILON) { return rounded; } throw new Error(`Invalid integer value passed to GraphQL: ${value}`); } if (Array.isArray(value)) { return value.map(item => sanitizeIntValues(item)); } if (value !== null && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([key, val]) => [key, sanitizeIntValues(val)]) ); } return value; } export const relayEnvironment = new Environment({ network: Network.create(fetchQuery), store: new Store(new RecordSource()), });
3. 业务代码传参时强制走类型转换
所有传给Int字段的计算值,都通过asInt方法转换后再传入,从开发阶段就提前拦截非法值:
const variables = { input: { bankAccountId: selectedAccountId, amount1Cents: asInt(calculateFirstDeposit()), amount2Cents: asInt(calculateSecondDeposit()), } };
方案优势
- 完全符合Schema First开发规范,不需要修改服务端Schema定义
- 类型约束由Relay编译流程自动生成,不会被后续编译操作覆盖
- 覆盖开发阶段类型校验、运行时参数合法性校验、序列化层兜底修正三层防护,比单纯手动取整的健壮性高一个量级
- 对现有业务代码侵入性极低,不需要修改已有的GraphQL操作定义
内容的提问来源于stack exchange,提问作者avocado
相关产品推荐
相关产品推荐

