DEV Community

Cover image for Your Java long IDs Are Lying to Your React Native App: Contract Rules Between Spring Boot and TypeScript
Chaminade Dondah ADJOLOU
Chaminade Dondah ADJOLOU

Posted on

Your Java long IDs Are Lying to Your React Native App: Contract Rules Between Spring Boot and TypeScript

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 (!)
Enter fullscreen mode Exit fullscreen mode

Table: what JSON.parse does to a Java long

"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,
    ...
) {}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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.

Pipeline: Java record → Jackson → springdoc → openapi-typescript → Zod

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
) {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.
  • [ ] null vs missing is a documented convention; required fields are marked.
  • [ ] TS types are generated from /v3/api-docs in 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 ≠ @JsonFormat trap).

Version notes

  • Spring Boot 4 uses Jackson 3. Packages moved from com.fasterxml.jackson to tools.jackson, except com.fasterxml.jackson.annotation (so @JsonFormat keeps its import). Jackson2ObjectMapperBuilderCustomizer became JsonMapperBuilderCustomizer. On Spring Boot 3.x, use the com.fasterxml imports 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-annotations to the version springdoc expects (2.2.55), otherwise /v3/api-docs threw NoSuchMethodError. Check mvn dependency:tree -Dincludes=io.swagger.core.v3 if 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)