DEV Community

Cover image for Solved: Zod: how to check if string is valid int64 while preserving string type?
Darian Vance
Darian Vance

Posted on • Originally published at wp.me

Solved: Zod: how to check if string is valid int64 while preserving string type?

🚀 Executive Summary

TL;DR: JavaScript’s native number type cannot safely represent 64-bit integers (int64) without precision loss, necessitating their representation as strings. This guide provides Zod solutions using .refine() or .superRefine() with BigInt to validate int64 strings for both format and range, while preserving the string type.

🎯 Key Takeaways

  • JavaScript’s number type is an IEEE 754 double-precision float, safely representing integers only up to Number.MAX\_SAFE\_INTEGER (2^53 – 1), which is insufficient for int64 values.
  • Zod’s z.coerce.number().int() will fail or lose precision for int64 values, making custom validation necessary.
  • BigInt is the appropriate JavaScript type for accurately handling and validating numbers that exceed Number.MAX\_SAFE\_INTEGER, including int64 ranges.
  • Zod’s .refine() and .superRefine() methods enable custom validation logic, allowing checks against BigInt boundaries while ensuring the validated type remains a string.
  • Encapsulating int64 string validation logic into a reusable helper function (e.g., zInt64()) promotes DRY principles, improves code readability, and centralizes maintenance.

Learn to validate if a string represents a valid 64-bit integer (int64) using Zod without coercing the type to a number. This guide explores several practical solutions for handling large numeric string inputs in TypeScript and Node.js environments.

The Problem: Validating Large Integers in JavaScript

In many systems, especially those interacting with databases (e.g., PostgreSQL’s BIGINT) or gRPC services, 64-bit integers (int64) are common. When these numbers are serialized in JSON, they are often represented as strings to avoid precision loss, as JavaScript’s native number type is an IEEE 754 double-precision float and can only safely represent integers up to Number.MAX_SAFE_INTEGER (which is 2^53 – 1, far smaller than the 2^63 – 1 of a signed int64).

Symptoms

You’re building an API or service with Zod and encounter the following challenges:

  • An incoming string field like "9223372036854775807" needs validation to ensure it’s a valid int64.
  • Using z.coerce.number().int() fails or loses precision for numbers larger than Number.MAX_SAFE_INTEGER.
  • You need to keep the validated value as a string to pass it to a database driver or another service that can handle large number strings correctly.
  • The goal is a Zod schema that confirms the string’s content represents an int64 but whose inferred type remains string.

Let’s explore three effective solutions to this problem, ranging from simple to robust and reusable.

Solution 1: Basic Validation with Regular Expressions

The simplest approach is to validate that the string contains only digits, with an optional leading minus sign. This doesn’t check the 64-bit range but is often sufficient for ensuring the string is a valid integer representation.

We can achieve this using z.string() combined with .regex() for a quick format check or .refine() for a more explicit validation.

Implementation

Here, we use .refine() to provide a clearer error message. The regex /^-?\d+$/ matches a string that starts with an optional hyphen and is followed by one or more digits.

import { z } from 'zod';

const integerStringSchema = z.string().refine((val) => /^-?\d+$/.test(val), {
  message: "Input must be an integer string.",
});

// --- Usage ---

// ✅ Valid
console.log(integerStringSchema.safeParse("12345"));
// Output: { success: true, data: '12345' }

console.log(integerStringSchema.safeParse("-987"));
// Output: { success: true, data: '-987' }

console.log(integerStringSchema.safeParse("999999999999999999999999")); // Passes format check
// Output: { success: true, data: '999999999999999999999999' }

// ❌ Invalid
console.log(integerStringSchema.safeParse("123.45"));
// Output: { success: false, ... }

console.log(integerStringSchema.safeParse("not-a-number"));
// Output: { success: false, ... }
Enter fullscreen mode Exit fullscreen mode

This method is fast and straightforward but critically incomplete: it does not validate that the number fits within the int64 range.

Solution 2: Robust Range Validation with BigInt

For true int64 validation, you must check if the number falls within the correct range: from -(2^63) to (2^63 – 1). The best way to handle numbers of this magnitude in JavaScript is with the built-in BigInt type.

We can use .refine() to attempt parsing the string as a BigInt and then compare it to the int64 min/max boundaries.

Implementation

We define the int64 boundaries as BigInt constants and then use them in our refinement logic.

import { z } from 'zod';

// Define 64-bit integer boundaries using BigInt literals
const INT64_MIN = -9223372036854775808n;
const INT64_MAX = 9223372036854775807n;

const int64StringSchema = z.string()
  .regex(/^-?\d+$/, "Input must be a valid integer string")
  .refine((val) => {
    try {
      const num = BigInt(val);
      return num >= INT64_MIN && num <= INT64_MAX;
    } catch (e) {
      // Should not happen due to the regex, but as a safeguard
      return false;
    }
  }, {
    message: "Input is out of the signed 64-bit integer range.",
  });

// --- Usage ---

// ✅ Valid (within range)
console.log(int64StringSchema.safeParse("9223372036854775807"));
// Output: { success: true, data: '9223372036854775807' }

console.log(int64StringSchema.safeParse("-9223372036854775808"));
// Output: { success: true, data: '-9223372036854775808' }

// ❌ Invalid (out of range)
console.log(int64StringSchema.safeParse("9223372036854775808")); // (max + 1)
// Output: { success: false, ... }

// ❌ Invalid (bad format)
console.log(int64StringSchema.safeParse("123-456"));
// Output: { success: false, ... }
Enter fullscreen mode Exit fullscreen mode

This approach is highly accurate and correctly enforces both the format and the range constraints, while ensuring the final data type remains a string.

Solution 3: Creating a Reusable Helper Function

If you need int64 string validation in multiple schemas across your application, repeating the .refine() logic is not ideal. A better practice is to encapsulate this logic into a reusable helper function that returns a configured Zod schema. This approach promotes DRY (Don’t Repeat Yourself) principles and leads to cleaner, more maintainable code.

Implementation

We’ll create a function, zInt64(), that builds and returns our complete validation schema. We’ll use .superRefine() here, which is ideal for complex validations that might add multiple issues.

import { z, ZodIssueCode } from 'zod';

const INT64_MIN_STR = "-9223372036854775808";
const INT64_MAX_STR = "9223372036854775807";
const INT64_MIN_BIGINT = BigInt(INT64_MIN_STR);
const INT64_MAX_BIGINT = BigInt(INT64_MAX_STR);

export function zInt64() {
  return z.string().superRefine((val, ctx) => {
    if (!/^-?\d+$/.test(val)) {
      ctx.addIssue({
        code: ZodIssueCode.custom,
        message: "Input must be a valid integer string.",
      });
      return;
    }

    try {
      const num = BigInt(val);
      if (num < INT64_MIN_BIGINT || num > INT64_MAX_BIGINT) {
        ctx.addIssue({
          code: ZodIssueCode.custom,
          message: `Input must be between ${INT64_MIN_STR} and ${INT64_MAX_STR}.`,
        });
      }
    } catch (e) {
      ctx.addIssue({
        code: ZodIssueCode.custom,
        message: "Failed to parse string as BigInt.",
      });
    }
  });
}

// --- Usage ---

// Now you can easily create schemas with this validation
const apiRequestSchema = z.object({
  userId: zInt64(),
  transactionId: zInt64(),
  someOtherField: z.string().min(1),
});

// ✅ Valid
console.log(apiRequestSchema.safeParse({
    userId: "123456789012345678",
    transactionId: "-1",
    someOtherField: "data"
}));
// Output: { success: true, data: { ... } }

// ❌ Invalid
console.log(apiRequestSchema.safeParse({
    userId: "999999999999999999999999999", // Out of range
    transactionId: "-1",
    someOtherField: "data"
}));
// Output: { success: false, ... }
Enter fullscreen mode Exit fullscreen mode

This solution provides the same robust validation as Solution 2 but packages it in a clean, reusable, and self-documenting function.

Comparison of Solutions

Method Pros Cons Best For
1. Basic Regex * Very simple to implement. * Fastest performance. * Does not check numeric range. * Can allow invalid int64 values. Scenarios where you only need to confirm the string looks like an integer, and range is not a concern or is validated elsewhere.
2. refine with BigInt * Completely accurate range and format validation. * Self-contained within a single schema definition. * Slightly more complex code. * Can be repetitive if used in many places. One-off validations where correctness is critical and reusability is not a primary concern.
3. Reusable Helper * Promotes DRY principles. * Makes schemas clean and readable. * Centralizes complex logic for easy maintenance. * Requires setting up a separate helper function/file. Any project where int64 string validation is needed in more than one place. This is the recommended approach for production applications.

Conclusion

When handling 64-bit integers as strings in a Node.js/TypeScript environment, Zod provides the flexibility to enforce strict validation rules while preserving the original string type. While a simple regex can check the format, using BigInt within a .refine() or .superRefine() block is the correct and safest way to validate the full int64 range. For maintainable and scalable applications, encapsulating this logic in a reusable helper function (Solution 3) is the most professional and robust approach.


Darian Vance

👉 Read the original article on TechResolve.blog


☕ Support my work

If this article helped you, you can buy me a coffee:

👉 https://buymeacoffee.com/darianvance

Top comments (0)