Zustand persist migrate: 4 Ways to Safely Narrow `persistedState unknown` Type with TypeScript
When persisting state to localStorage with Zustand, there inevitably comes a moment when you need to change the schema. Renaming fields, adding new ones, flattening nested structures. That's when you reach for the persist middleware's migrate option — and the first time you encounter it, there's one type that can be quite bewildering: persistedState: unknown.
I remember my own first reaction: "Why isn't this inferred as MyState?" I patched over the type error with as any and moved on. Two months later, a bug surfaced where the username field stored in user storage was silently overwritten with undefined, and it took half a day to track down the cause. This article explains why the unknown type is an intentional design decision, and walks through four patterns for handling it safely in a TypeScript strict environment.
Once you understand it, it's nothing special — but without that understanding, you'll keep reaching for as any. Let's pinpoint exactly where that subtle gap lies.
Core Concepts
Why persistedState Is unknown
Zustand's persist middleware serializes store state to external storage like localStorage or sessionStorage, then reads it back when the app reloads. The key moment is "when it reads back."
External storage is a browser-managed space. A user might have modified values directly using developer tools, or a previous version of the app might have stored data under an entirely different schema. This is not a domain TypeScript can guarantee at compile time.
// The migrate function signature in PersistOptions
migrate?: (persistedState: unknown, version: number) => S | Promise<S>That's why Zustand declares persistedState as unknown: "We don't know the actual shape of this value — the developer must validate it themselves."
unknownvsany:anycompletely abandons type checking, whileunknownis TypeScript's safety mechanism saying "the type is unknown, but you must narrow it before using it." You cannot even access properties on anunknownvalue without type narrowing.
The Generic Structure of PersistOptions and the version Parameter
Looking at the type parameters of the persist middleware makes the design intent even clearer.
// S: the full store state type
// PersistedState: the type actually stored (may differ from S when using partialize)
type PersistOptions<S, PersistedState = S>If you use the partialize option to store only part of the state, PersistedState becomes a subset of S. For example, when storing only a token while excluding sensitive data, the situation becomes a bit awkward.
persist(
(set) => ({ token: '', username: '', count: 0 }),
{
partialize: (state): Pick<MyState, 'token'> => ({ token: state.token }),
migrate: (persistedState, version) => {
// We know the stored shape is { token: string },
// but the type is still unknown — the developer must narrow it manually
}
}
)The second argument to migrate, version, is read from the __zustandVersion field that Zustand serializes alongside the state when saving. Since it extracts the version number stored in storage and passes it to the migration function, understanding this flow helps you reason about version comparison logic. The merge function has the same (persistedState: unknown, currentState: S) => S signature for the same reason.
Practical Application
All examples use the migration scenario { count: number; name: string } → { count: number; username: string; createdAt: number }.
Example 1: as Casting — Fast but Dangerous
The most intuitive approach. Honestly, for simple internal tools or prototypes, this is sometimes sufficient. That said, it's not recommended for production code.
type StateV0 = { count: number; name: string };
type CurrentState = { count: number; username: string; createdAt: number };
migrate: (persistedState, version) => {
if (version === 0) {
const v0 = persistedState as StateV0;
return {
count: v0.count,
username: v0.name,
createdAt: Date.now(),
};
}
return persistedState as CurrentState;
}This asserts to TypeScript that "this value is StateV0" — but if the actual runtime value is something else, the compiler says nothing. The core risk of this pattern is that corrupted storage values can silently flow through as incorrect data.
| Item | Details |
|---|---|
| Suitable for | Prototypes, internal tools, early development requiring quick validation |
| Risk | Silent runtime failure when storage value doesn't match the expected shape |
| Type safety | Low — TypeScript has given up on validation |
Example 2: Assertion Function — Changing the Type Flow After a Function Call
Type assertion functions are a pattern introduced in TypeScript 3.7. When declared with the asserts val is T form, TypeScript infers the variable as type T from the point of that function call onward.
Unlike as casting, which "changes the type immediately at that line," an assertion function "propagates the type change throughout the entire control flow after the function call." Because the function throws an exception when validation fails, the compiler also concludes that "after this function returns, the type is guaranteed."
// Declared as asserts val is Partial<StateV0>,
// but the actual runtime check only verifies "is it an object?"
function assertMyState(val: unknown): asserts val is Partial<StateV0> {
if (typeof val !== 'object' || val === null) {
throw new Error('Invalid persisted state: not an object');
}
}
migrate: (persistedState, version) => {
assertMyState(persistedState);
// From this line on, TypeScript infers persistedState as Partial<StateV0>
// However, individual field types are not guaranteed — use optional chaining and defaults
if (version === 0) {
return {
count: persistedState.count ?? 0,
username: persistedState.name ?? 'guest',
createdAt: Date.now(),
};
}
// Current version — if you're uneasy about unchecked assertion here, consider switching to Example 4 (Zod)
return persistedState as unknown as CurrentState;
}One thing to note: this assertion function only checks "is it an object?" at runtime. It does not verify that the name field is actually a string or that count is a number. Compared to as casting, it's more accurate to understand this as "a pattern with minimal runtime guarding and type propagation added" rather than "a fundamentally different level of safety." That's why pairing it with Partial<> wrapping and ?? defaults is far more important for practical protection.
| Item | Details |
|---|---|
| Suitable for | Projects with simple structures and infrequent version changes |
| Runtime protection level | "Null and non-object elimination" only — individual field types unverified |
| Type safety | Higher than as casting, lower than Zod |
Example 3: Record<string, unknown> Intermediate Casting — Version Chain Documentation Pattern
When migration chains run v0 → v1 → v2, declaring separate types for each version dramatically improves code readability. My first reaction was "do we really need to go this far?" — but once you have three or more migration stages stacked in a codebase, you realize how valuable those type declarations are.
type StateV0 = { count: number; name: string };
type StateV1 = { count: number; username: string };
type CurrentState = { count: number; username: string; createdAt: number };
migrate: (persistedState, version) => {
// Narrow to Record<string, unknown> first, then apply per-field type guards
const state = persistedState as Record<string, unknown>;
if (version === 0) {
return {
count: typeof state.count === 'number' ? state.count : 0,
username: typeof state.name === 'string' ? state.name : 'guest',
createdAt: Date.now(),
};
}
if (version === 1) {
const v1 = persistedState as StateV1; // casting for intent documentation
return { ...v1, createdAt: Date.now() };
}
return persistedState as CurrentState;
}The benefit of this pattern lies more in "documenting migration intent" than in type safety. Declaring version types like StateV0 and StateV1 at the top of the file lets you grasp "what the v0 schema looked like" at a glance. The as StateV1 and as CurrentState casts are unverified assertions with no runtime checks — identical to Example 1 in that regard. Actual runtime protection only exists where Record<string, unknown> intermediate casting is combined with per-field typeof guards.
| Item | Details |
|---|---|
| Suitable for | Migration chains with 2+ stages, projects without Zod |
| Benefits | Per-version schema documentation, clear code intent |
| Type safety | High where field guards are applied, low where as assertions are used |
Example 4: Zod safeParse — The Highest Runtime Safety Approach
If your project already uses Zod, combining it with the migrate function is the most powerful choice. It handles both type narrowing and runtime validation in one go, without any as assertions.
import { z } from 'zod';
const V0Schema = z.object({
count: z.number(),
name: z.string(),
});
const V1Schema = z.object({
count: z.number(),
username: z.string(),
createdAt: z.number(),
});
migrate: (persistedState, version) => {
if (version === 0) {
const parsed = V0Schema.safeParse(persistedState);
// Initialize with safe defaults on parse failure
if (!parsed.success) {
return { count: 0, username: 'guest', createdAt: Date.now() };
}
return {
count: parsed.data.count,
username: parsed.data.name,
createdAt: Date.now(),
};
}
// Current version — use parse, which throws on validation failure
return V1Schema.parse(persistedState);
}safeParse returns { success: false, error } instead of throwing on validation failure, so even when storage data is corrupted, the app recovers with defaults rather than crashing completely. For the current version, parse is used instead, explicitly throwing on schema mismatch. This branching carries the intent: "recover from past versions as much as possible, but be strict about the current version." The experience of the app silently recovering to defaults when storage data is corrupted at runtime is quite reassuring.
| Item | Details |
|---|---|
| Suitable for | Projects already using Zod, production environments where runtime protection matters |
| Runtime protection level | Validates all field types |
| Type safety | Highest — can be handled without any as assertions |
Pros and Cons Analysis
In practice, the more common issue isn't the pattern choice itself, but not knowing the criteria for when to use what. Here's a summary table.
Pattern Selection Criteria
| Pattern | Runtime Protection | Adoption Cost | Use When |
|---|---|---|---|
as casting |
None | None | Prototypes, internal tools |
| Assertion Function | Object check only | Low | Simple structure, no Zod |
Record<string, unknown> + version types |
When field guards applied | Low | Migration chain documentation |
Zod safeParse |
All fields | Medium (Zod install) | Production, Zod projects |
Type safety ranking: Zod
safeParse> Assertion Function >Record<string, unknown>casting >asassertion. Choosing the right level based on project scale and migration complexity is the pragmatic approach.
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
Risk of as overuse |
as any or indiscriminate as MyState can nullify type safety |
Use Assertion Function or Zod patterns |
Awkwardness with partialize |
Even when you know the stored value is Pick<MyState, 'token'>, it must be treated as unknown |
Recommend explicit declaration with a separate type variable |
| Complex migration chains | In v0→v1→v2→v3 chains, type safety is hard to maintain without per-version type definitions | Use the StateV0, StateV1 explicit pattern |
| Async migration | Promise<S> return increases type inference complexity |
Explicitly annotate return types |
Most Common Mistakes in Practice
-
Casting directly with
persistedState as MyState, then omitting handling for when fields areundefined, causing runtime errors. Casting asPartial<MyState>and using?? defaultsis safer. -
Using
<=or>=instead of===forversioncomparisons, causing migrations to run multiple times or be skipped. It's recommended to match each version exactly with===. -
In Zustand v5, the behavior of automatically persisting the initial store state was removed. When upgrading from v4 to v5, migration code can behave unexpectedly if this change isn't accounted for.
Closing Thoughts
persistedState: unknown is not a bug — it's Zustand's explicit design message telling you not to trust data from external storage.
If you're currently using migrate in your project, here's a checklist worth working through:
- Find any
as anyor unconditionalas MyStatein your current code, and consider adding at minimum atypeof val === 'object' && val !== nullcheck. - If you have two or more migrations stacked up, it's worth declaring version-specific types like
type StateV0andtype StateV1at the top of the file. Your colleagues — or your future self — six months from now will thank you. - If your project already uses Zod, consider switching to a pattern that defines each version's schema with
z.object()and combines it withsafeParse.
References
- Persisting store data | Zustand Official Docs
- persist Middleware Reference | Zustand Official Docs
- Return type of migrate in zustand persist · Discussion #2357 | GitHub
- (middleware/persist): Persisted State Type · Discussion #1329 | GitHub
- TypeScript type conflict with persist middleware · Issue #650 | GitHub
- Persist + TypeScript + Slices type errors · Discussion #2164 | GitHub
- Zod with zustand · Discussion #1722 | GitHub
- zustand/docs/migrations/migrating-to-v5.md | GitHub
- NextJS + Zustand + TypeScript type conflict with persist middleware | Medium
- GitHub - SebastianJarsve/zod-persist