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.
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 functionThe 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()oritem.createdAt.toLocaleDateString()throwsTypeError: not a functionbecause 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 astring. 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
| Approach | Pros | Cons | Complexity | Best For |
|---|---|---|---|---|
| ISO-8601 strings | Simplest, no dependencies | Manual conversions required | Low | Small projects, simple schemas |
| Custom reviver | Fine-grained control, no extra deps | Boilerplate code, must maintain manually | Medium | Mid-size apps with specific date fields |
| Superjson | Automatic type handling, minimal code | Extra dependency (~9KB), serialization overhead | Low (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
Datefields - 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 👋