You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 07:06:19