This bug doesn't throw. Your Spring Boot API returns an order with id 1849203948123456789. Your React Native app shows the order and lets the user tap "Cancel". Then it sends DELETE /api/orders/1849203948123456800. The server answers 404, or worse, it cancels a different order.
Nothing crashed and every type checked. The API and the client simply disagree about what a number is.
A popular DEV post this week asked how to validate unknown in TypeScript without a schema library, and another reminded us that Twitter added id_str in 2010 because JavaScript can't hold a Java long. This post puts the two together, from the point of view of a full-stack developer who owns both ends: a Spring Boot backend and a TypeScript client (React, React Native or both).
Everything below was run on Spring Boot 4.1.1 (Jackson 3.1.5), springdoc-openapi 3.1.1, openapi-typescript 7.13, Zod 4.6 and Node 20. Version notes are in the last section.
1. The precision cliff: 2^53
Java's long is a 64-bit integer. A JavaScript number is a 64-bit IEEE 754 float, so it can represent every integer exactly only up to Number.MAX_SAFE_INTEGER, which is 2^53 − 1 = 9,007,199,254,740,991. Past that, integers get rounded to the nearest representable value.
JSON.parse doesn't warn you. It produces the nearest double:
// Node 20, run as-is
const o = JSON.parse('{"id":1849203948123456789,"customerId":9007199254740993}');
console.log(o.id); // 1849203948123456800
console.log(o.customerId); // 9007199254740992 (off by one)
console.log(Number.isSafeInteger(o.id)); // false
console.log(1849203948123456789 === 1849203948123456800); // true (!)
"My IDs come from a Postgres bigserial, they'll never reach 2^53," you might say. That's often true for auto-increment keys. But many modern ID schemes deliberately use the high bits:
- Snowflake-style IDs (timestamp + worker + sequence), used by many social and messaging APIs;
- TSID / time-sorted IDs, which are popular in Java backends because they index well;
-
external IDs from payment providers, telcos or partners that you store as
long.
All of these sit far above 2^53. The day you switch ID generators, or integrate a partner, your client starts rounding.
2. Rule 1: 64-bit integers travel as strings
Treat an ID as an opaque token. You never do arithmetic on it, so it doesn't need to be a number on the wire.
On the Spring Boot side you have two options.
Per field, with an annotation:
import com.fasterxml.jackson.annotation.JsonFormat;
record OrderDto(
@JsonFormat(shape = JsonFormat.Shape.STRING) Long id,
...
) {}
Globally, so nobody can forget. With Spring Boot 4 (Jackson 3), register a module through a JsonMapperBuilderCustomizer:
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.module.SimpleModule;
import tools.jackson.databind.ser.std.ToStringSerializer;
@Configuration
class JsonConfig {
// Every Long / long in every response body is written as a JSON string.
@Bean
JsonMapperBuilderCustomizer longsAsStrings() {
SimpleModule module = new SimpleModule("longs-as-strings");
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
return builder -> builder.addModule(module);
}
}
The response now looks like this (actual output from the test app):
{"id":"1849203948123456789","customerId":"9007199254740993","createdAt":"2026-10-19T09:30:00Z",
"status":"PAID","total":"15000.50","note":null,"itemCount":3}
Note that itemCount is an int, so it stays a number. That's what you want: small integers are safe.
Inbound, Jackson is lenient. By default, it accepted both "customerId":"9007199254740993" and "customerId":9007199254740993 for a Long field in a request body, and it returned 400 for "abc". So the client can send strings without any change on the server. Clients that still send numbers won't break during a migration either.
The global option has a trade-off: it's a breaking change for existing clients that expect numbers. Roll it out behind a new API version, or start with the per-field annotation on IDs only.
3. Rule 2: money is a decimal string, not a float
BigDecimal has the same problem in a different form. Jackson writes new BigDecimal("15000.50") as the JSON number 15000.50, and JSON.parse turns it into 15000.5. The scale is gone. For amounts above 2^53 minor units (rare, but possible in currencies with large nominal values), precision goes too. And then someone writes 0.1 + 0.2 in the client and gets 0.30000000000000004.
Send money as a decimal string ("15000.50"), or as an integer count of minor units plus a currency code. On the client, format it for display and do any arithmetic with a decimal library, or on the server.
I hit a subtle trap while testing this. I annotated total with @Schema(type = "string") for the docs, but Jackson still serialized it as a number. The OpenAPI annotation documents the contract; it doesn't enforce it. You need @JsonFormat(shape = STRING) (or a global BigDecimal serializer) and the schema annotation. Section 6 shows how to catch that mismatch automatically.
4. Rule 3: dates, enums and nulls
Dates. Use Instant (or OffsetDateTime) on the server and ISO-8601 with an offset on the wire: "2026-10-19T09:30:00Z". With Spring Boot 4 / Jackson 3, the test app produced that by default, with no WRITE_DATES_AS_TIMESTAMPS tweak needed. On Spring Boot 3 / Jackson 2, check your config: older setups often emit epoch numbers. Avoid LocalDateTime in APIs. It has no zone, so "09:30" means something different in Lomé, Paris and São Paulo.
Enums. Java enums serialize as their names. That's fine, as long as the client is ready for a value it has never seen. When you add REFUNDED next quarter, an old app build on someone's phone will receive it. On mobile you can't force everyone to update, so the client must have a fallback branch ("unknown status") instead of crashing in a switch.
Nulls vs missing. "note": null and a missing note are different things in TypeScript (string | null vs note?: string). Pick one convention per API and write it down. The setup below makes required-but-nullable explicit.
5. Rule 4: generate the TypeScript types, don't hand-copy them
Hand-written interface Order { id: number } files are where these bugs live. Generate the client types from the server's OpenAPI document instead.
Server: describe the DTO precisely. springdoc reads your records. Without hints it describes Long as integer/int64 (true for Java, wrong for your JSON) and marks every field optional. Annotate:
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
record OrderDto(
@Schema(type = "string", requiredMode = REQUIRED, example = "1849203948123456789") Long id,
@Schema(type = "string", requiredMode = REQUIRED) Long customerId,
@Schema(requiredMode = REQUIRED) Instant createdAt,
@Schema(requiredMode = REQUIRED) OrderStatus status,
@JsonFormat(shape = JsonFormat.Shape.STRING)
@Schema(type = "string", requiredMode = REQUIRED, example = "15000.50") BigDecimal total,
@Schema(requiredMode = REQUIRED, types = {"string", "null"}) String note,
@Schema(requiredMode = REQUIRED) int itemCount
) {}
Client: generate types.
curl -s http://localhost:8080/v3/api-docs > api-docs.json
npx openapi-typescript api-docs.json -o src/api/schema.d.ts
The generated type, unedited:
OrderDto: {
/** @example 1849203948123456789 */
id: string;
customerId: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
status: "PENDING" | "PAID" | "SHIPPED" | "CANCELLED";
/** @example 15000.50 */
total: string;
note: string | null;
/** Format: int32 */
itemCount: number;
};
Before I added requiredMode, every field came out as id?: string, and the client had to null-check everything. Run this generation in CI and commit the output, so a backend change shows up as a diff in the client.
6. Rule 5: check at the boundary, at runtime
Generated types are a compile-time promise. The bytes that reach a phone over a flaky mobile network are not. A proxy can return HTML, an old server version can still be deployed, or someone can forget @JsonFormat. Validate once, where unknown enters the app:
import { z } from 'zod';
import type { components } from './api/schema';
type OrderDto = components['schemas']['OrderDto'];
const Id = z.string().regex(/^\d{1,19}$/, 'expected a numeric string id');
export const OrderSchema = z.object({
id: Id,
customerId: Id,
createdAt: z.iso.datetime({ offset: true }),
status: z.enum(['PENDING', 'PAID', 'SHIPPED', 'CANCELLED']),
total: z.string().regex(/^-?\d+(\.\d+)?$/),
note: z.string().nullable(),
itemCount: z.number().int(),
});
// Compile-time check: runtime schema and generated type must agree both ways.
type Parsed = z.infer<typeof OrderSchema>;
const _a: OrderDto = {} as Parsed;
const _b: Parsed = {} as OrderDto;
If the server regresses and sends a numeric id, parsing fails loudly instead of rounding silently:
OrderSchema.safeParse({ ...order, id: 1849203948123456789 })
→ success: false, "Invalid input: expected string, received number"
The two _a / _b lines are a cheap trick: if someone edits the Zod schema or regenerates the types and they drift, tsc fails. For enums on mobile, swap z.enum([...]) for z.string() plus a mapping with an "unknown" fallback, so a new server value doesn't take down old app builds (see rule 3).
Don't count on clever JSON.parse tricks either. Newer JavaScript engines expose the raw source text to revivers, but you can't rely on that across every runtime your app ships on (Hermes, older WebViews). Fix the wire format instead.
A checklist for your next API review
- [ ] Every 64-bit ID is a JSON string (globally or per field).
- [ ] Money is a decimal string or integer minor units + currency, never a float.
- [ ] Timestamps are
Instant/OffsetDateTime, ISO-8601 with offset. - [ ] The client handles unknown enum values without crashing.
- [ ]
nullvs missing is a documented convention; required fields are marked. - [ ] TS types are generated from
/v3/api-docsin CI, not hand-written. - [ ] A runtime schema (Zod or hand-written guards) validates responses at the boundary.
- [ ] A test asserts that the serialized JSON matches the OpenAPI types (catches the
@Schema≠@JsonFormattrap).
Version notes
-
Spring Boot 4 uses Jackson 3. Packages moved from
com.fasterxml.jacksontotools.jackson, exceptcom.fasterxml.jackson.annotation(so@JsonFormatkeeps its import).Jackson2ObjectMapperBuilderCustomizerbecameJsonMapperBuilderCustomizer. On Spring Boot 3.x, use thecom.fasterxmlimports and the old customizer name; the idea is identical. - springdoc 3.x targets Spring Boot 4. In a project that also pulled in Spring AI, I had to pin
swagger-annotationsto the version springdoc expects (2.2.55), otherwise/v3/api-docsthrewNoSuchMethodError. Checkmvn dependency:tree -Dincludes=io.swagger.core.v3if you see that. - The code samples are from a small test app I ran locally. Adapt package names and versions to your project.
What's your team's convention: IDs as strings everywhere, or only above 2^53? Tell me in the comments.


Top comments (0)