bitglow logo
Back to overview
TechnicalTypeScriptZustand

Serializing Date Objects in Persisted Data Stores

Getting 'is not a function' on a Date object? Learn why JSON.stringify breaks Dates in persisted Zustand stores and how to fix cryptic runtime errors.

JB

Jonathan Bones

Senior Frontend Engineer

April 29, 2026

Date Serialization in Persisted Data Stores: The Hidden Trap

As a developer, you've likely encountered your fair share of perplexing bugs. Still, seeing a runtime error in statically typed TypeScript can feel like the ground shifting under your feet. This week, the culprit was Date serialization in a persisted Zustand store:

❌ Uncaught TypeError: item.createdAt.toLocaleDateString is not a function

In this post, you'll learn why this happens, what breaks downstream, and how to fix it with three battle-tested approaches.

TL;DR

Date values do serialize to JSON, but they don't round-trip back into Date instances. Persist dates as ISO-8601 strings, take control of serialization/deserialization with a custom reviver or use a library like Superjson to handle the heavy lifting.

Code snippets in this article were tested with typescript@5.9.2, zustand@5.0.12, superjson@2.2.6, and @react-native-async-storage/async-storage@2.2.0.

How JSON Encoding "Breaks" Date Objects

Since the project was using TypeScript, this was an unexpected runtime issue. Following the stack trace and references led me to the Zustand store.

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
 
type Item = {
  name: string;
  createdAt: Date;
};
 
type State = {
  items: Item[];
};
 
type Actions = {
  addItem: (item: Item) => void;
};
 
type ItemStore = State & Actions;
 
const storage = createJSONStorage(() => AsyncStorage);
 
export const useItemStore = create<ItemStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item: Item) => set({ items: [...get().items, item] }),
    }),
    {
      name: "item-storage",
      storage,
    },
  ),
);

Nothing unusual going on here: a simple, persisted store of items. The issue is that Date instances don’t survive the persistence serialization boundary intact.

When JSON.stringify() encounters a Date, it automatically calls the object's .toJSON() method. For Date objects, this returns an ISO-8601 string. So your Date instance becomes a plain string like "2026-03-24T09:30:00.000Z". This is intentional behavior in JavaScript, but it creates a problem when reconciling the persisted data.

Here's the issue in isolation:

const dateTest = { start: new Date() };
console.log(dateTest.start instanceof Date); // ✓ true
 
// JSON.stringify converts Date to ISO string
const stored = JSON.stringify(dateTest);
console.log(stored); // {"start":"2026-01-16T10:30:00.000Z"}
 
// JSON.parse returns a plain object with string properties
const rehydrated = JSON.parse(stored);
console.log(rehydrated.start instanceof Date); // ✗ false - it's now a string!
console.log(typeof rehydrated.start); // "string"
console.log(rehydrated.start.getTime()); // TypeError: not a function

The Impact of Silent Type Conversion

This silent type conversion causes real runtime problems that TypeScript alone won't catch:

  • Instance method calls fail: Code like item.createdAt.getTime() or item.createdAt.toLocaleDateString() throws TypeError: not a function because strings don't have Date methods
  • Comparisons get weird: String comparisons ("2026-01-16" > "2026-01-15") work differently than date comparisons
  • Type mismatch: Your TypeScript types say Date, but the runtime has a string. TypeScript won't warn you at the call site
  • Data integrity issues: Downstream calculations that assume Date objects fail silently or produce wrong results

Solution Strategies for Handling Date Objects

Fortunately, there are a few ways to avoid issues, each with its own trade-offs.

1. Simply Use an ISO-8601 String

This is the simplest approach: store dates as ISO-8601 strings instead of Date objects. No custom reviver needed and strings are universally compatible.

type Item = {
  name: string;
  createdAt: string; // ISO-8601 string instead of Date
};
 
// When adding an item
const item: Item = {
  name: "Widget",
  createdAt: new Date().toISOString(), // Convert to string at the boundary
};
 
// When reading, convert back if needed
const date = new Date(item.createdAt);
console.log(date.toLocaleDateString()); // Works!

Trade-off: You lose direct access to Date methods; every use requires conversion. This works fine for simple schemas but performing conversion at every callsite can become tedious.

2. Use createJSONStorage with a custom replacer + reviver

The JSON.stringify and JSON.parse methods both accept optional functions, replacer and reviver, to hook into the serialization and deserialization process.

A replacer function alters the behavior of JSON.stringify. It can be used to transform values before they are converted to strings. However, when JSON.stringify encounters a Date object, it calls its toJSON() method first, and the result (an ISO string) is what’s passed to the replacer. In other words: in a replacer, a Date is already a string.

A reviver function is passed to JSON.parse and is invoked for each key and value, allowing you to transform the data as it's being parsed. This is where you can turn the date strings back into Date objects.

A robust strategy is to “tag” date strings during serialization and untag them during parsing.

const storage = createJSONStorage(() => AsyncStorage, {
  replacer: (_key, data) => {
    // Dates are already ISO strings by this point (via Date.toJSON())
    // For fields we know are dates, wrap them in a tagged object
    if (_key === "createdAt" && typeof data === "string") {
      return { __type: "Date", value: data };
    }
    return data;
  },
  reviver: (_key, data) => {
    // Reconstruct Date objects from tagged values during parsing
    if (
      data &&
      typeof data === "object" &&
      "__type" in data &&
      data.__type === "Date" &&
      "value" in data &&
      typeof data.value === "string"
    ) {
      return new Date(data.value);
    }
    return data;
  },
});

This approach explicitly tags dates in the persisted JSON ({"__type": "Date", "value": "..."}) so the reviver can reliably reconstruct them.

💡

The tradeoff: you must identify which fields are dates upfront, and if your data structure changes, the logic will require an update.

3. Use Superjson for Automatic Type Handling

For complex applications, specialized serialization libraries handle Date objects—and many other types like Map, Set, Infinity, and RegExp—automatically. Superjson is a popular choice.

Zustand doesn't expose a serializer option on createJSONStorage, but you can supply a fully custom storage implementation instead. This is the cleanest way to integrate Superjson:

Here’s a React Native + AsyncStorage-friendly example:

import { create } from "zustand";
import { persist, type PersistStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import superjson from "superjson";
 
const storage = {
  getItem: async (name) => {
    const str = await AsyncStorage.getItem(name);
    return str ? superjson.parse(str) : null;
  },
  setItem: async (name, value) => {
    await AsyncStorage.setItem(name, superjson.stringify(value));
  },
  removeItem: async (name) => {
    await AsyncStorage.removeItem(name);
  },
} satisfies PersistStorage<ItemStore>;
 
export const useItemStore = create<ItemStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item: Item) => set({ items: [...get().items, item] }),
    }),
    {
      name: "item-storage",
      storage,
    },
  ),
);

With Superjson, supported types like Date, Map, Set, and RegExp round-trip without custom reviver/replacer logic.

Choosing the Right Approach for Your Use Case

ApproachProsConsComplexityBest For
ISO-8601 stringsSimplest, no dependenciesManual conversions requiredLowSmall projects, simple schemas
Custom reviverFine-grained control, no extra depsBoilerplate code, must maintain manuallyMediumMid-size apps with specific date fields
SuperjsonAutomatic type handling, minimal codeExtra dependency (~9KB), serialization overheadLow (library handles it)Complex schemas, multiple non-JSON types

Key Takeaways

The silent transformation of Date objects into strings during persistence is a classic JavaScript gotcha that leads to subtle, hard-to-diagnose runtime errors. JSON.stringify and JSON.parse work as designed—they just don't preserve type information for non-primitive values.

Remember:

  • Dates don't round-trip: Always account for deserialization when persisting stores with Date fields
  • TypeScript won't catch it: Type safety is static; this is a runtime issue
  • Plan ahead: Choose a serialization strategy early. Retrofitting is painful

Whether you use ISO strings, a custom reviver, or Superjson, the goal is ensuring data integrity across serialization boundaries. By understanding this limitation, you'll build more resilient applications and avoid the hidden traps of date serialization.

Have you hit a similar TypeScript issue, or found a smarter way to handle Date serialization? We'd love to hear from you! Drop us a line at hi@bitglow.de 👋