datavolve brings an old persisted value forward to its latest shape through a chain of forward-only evolutions. Point it at whatever you persisted last year (localStorage, IndexedDB, a synced blob, a config file) and get back the shape your code expects today, with full type inference along the way.
Features
- Full type inference through the evolution chain - no intermediate annotations
- Forward-only, pure
(prev) => nexttransforms - no reverse evolutions, no side effects - Total
run- returns a discriminated result, never throws - Storage-agnostic - the version comes in as an argument;
datavolvenever reads or writes it - Zero dependencies, tiny (258 B), ESM-only
Installation
pnpm add datavolve npm install datavolve yarn add datavolve bun add datavolve Requires an ESM environment. No peer dependencies.
Core Concepts
Real apps change the shape of the data they persist. A returning user’s browser can hold data written by a version of your app from six months ago. Without an evolution step that old value flows into new code and either crashes or, worse, silently corrupts state. datavolve is the small primitive that closes that gap: declare how each version differs from the last, and let it walk any stored value up to the current shape.
It is useful wherever the shape of persisted client data evolves over time:
- localStorage / sessionStorage state
- IndexedDB records
- A synced JSON blob or config file
- Any versioned value read back by newer code
Usage
Step 1: Declare your evolutions
You add evolutions in order, starting at version 1. Each one takes the previous version’s value and returns the next. The input to the first evolution is unknown on purpose - it is genuinely untrusted data, and narrowing it is the evolution’s job.
import { datavolve } from "datavolve";
const settings = datavolve()
.add(1, (raw) => ({ theme: "light" })) // legacy -> v1
.add(2, (v1) => ({ ...v1, fontScale: 1 })) // v1 -> v2
.add(3, (v2) => ({ ...v2, theme: v2.theme === "light" ? "day" : "night" })); // v2 -> v3
Step 2: Run against stored data
You tell run what version the incoming data is; datavolve never looks for a version field inside your value. Data with no version at all is version 0 and runs the whole chain.
const result = settings.run(storedValue, storedVersion);
Step 3: Handle the result
run returns a discriminated result, so the compiler makes you handle failure. Each error code carries exactly the fields relevant to it. On success the result carries the evolved value together with the version it is now at (always latestVersion), so you can persist the upgraded data in one place.
if (result.success) {
useSettings(result.value); // typed as the v3 shape
save({ version: result.version, value: result.value }); // store them together
} else {
switch (result.error.code) {
case "ahead":
// stored data is newer than this build understands
promptUserToUpdateApp(result.error.latestVersion);
break;
case "malformed":
// fromVersion was negative or non-integer
resetToDefaults();
break;
case "failed":
// one of your evolutions threw; the original error is in `cause`
report(result.error.cause);
resetToDefaults();
break;
}
}
See Version Behavior for every fromVersion case run handles, including the edge cases.
Narrowing Untrusted Input
The first evolution’s input is unknown. Narrow it with a runtime check, not a cast. Either a small type guard (zero dependencies):
const isRecord = (x: unknown): x is Record<string, unknown> =>
typeof x === "object" && x !== null;
datavolve().add(1, (legacy) => {
const old = isRecord(legacy) ? legacy : {};
return { theme: old["theme"] === "light" ? "light" : "dark" };
});
or a validator you already use (Valibot, Zod, etc.):
import * as v from "valibot";
const legacySchema = v.object({ theme: v.optional(v.string()) });
datavolve().add(1, (legacy) => {
const old = v.parse(legacySchema, legacy); // throws on garbage -> reported as `failed`
return { theme: old.theme === "light" ? "light" : "dark" };
});
A raw as would skip the check and let malformed legacy data through untyped - exactly what you don’t want at this boundary.
Validation
datavolve does no validation of its own - the inferred output type of run is a claim each evolution makes, not a runtime guarantee that untrusted stored data matches. Make it a guarantee by driving each evolution with a schema. Define one schema per version (each reusing the previous where it can), and let every evolution validate the shape it receives and transform it to the next with Valibot’s transform pipeline:
import * as v from "valibot";
// One schema per version - each reuses the previous one's entries where it can.
const settingsV1 = v.object({ theme: v.picklist(["light", "dark"]) });
const settingsV2 = v.object({ ...settingsV1.entries, fontScale: v.number() });
const settingsV3 = v.object({ ...settingsV2.entries, sidebar: v.boolean() });
// v4 deprecates `sidebar`: omit the key, keep the rest.
const settingsV4 = v.omit(settingsV3, ["sidebar"]);
const settingsV5 = v.object({
...settingsV4.entries,
theme: v.picklist(["day", "night"]),
});
// update this when a new version is added to the chain
const settingsLatest = settingsV5;
type Settings = v.InferOutput<typeof settingsLatest>;
// Each evolution validates the previous shape and transforms it to the next.
const toV2 = v.pipe(
settingsV1,
v.transform((s) => ({ ...s, fontScale: 1 })),
);
const toV3 = v.pipe(
settingsV2,
v.transform((s) => ({ ...s, sidebar: true })),
);
const toV4 = v.pipe(
settingsV3,
v.transform(({ sidebar, ...rest }) => rest),
);
const toV5 = v.pipe(
settingsV4,
v.transform((s) => ({
...s,
theme: s.theme === "light" ? ("day" as const) : ("night" as const),
})),
);
const settings = datavolve()
.add(1, (raw) => v.parse(settingsV1, raw)) // legacy -> v1
.add(2, (prev) => v.parse(toV2, prev))
.add(3, (prev) => v.parse(toV3, prev))
.add(4, (prev) => v.parse(toV4, prev))
.add(5, (prev) => v.parse(toV5, prev));
const result = settings.run(storedValue, storedVersion);
if (result.success) {
const value: Settings = result.value; // inferred *and* runtime-checked
useSettings(value);
}
Every evolution parses its input, so a value is validated at whatever version it enters the chain - corrupt or unexpected data surfaces as a failed result (v.parse throws, and run turns any thrown error into failed). You handle it through the same branch you already handle, with no extra try/catch.
The only value not re-checked is one already at latestVersion: it skips the chain and is returned unchanged (the no-op fast path). That is the shape your app just wrote, so it is trusted.
Persisting to localStorage
datavolve never touches storage - you own the { version, value } envelope and decide where it lives. A typical localStorage read evolves the stored value up to the latest shape and re-stores it, so the next read is a no-op; a fresh write stamps the current latestVersion:
const KEY = "settings";
// Read: evolve stored data up to the latest shape, then re-store it.
function load(): Settings {
const raw = localStorage.getItem(KEY);
const stored = raw ? JSON.parse(raw) : { version: 0, value: null }; // no data -> version 0
const result = settings.run(stored.value, stored.version);
if (!result.success) return defaults; // reset on ahead / malformed / failed
// persist the upgraded shape so the next read skips the chain
localStorage.setItem(
KEY,
JSON.stringify({ version: result.version, value: result.value }),
);
return result.value;
}
// Write: a fresh in-app change is already the latest shape, so stamp latestVersion.
function save(value: Settings): void {
localStorage.setItem(
KEY,
JSON.stringify({ version: settings.latestVersion, value }),
);
}
Design Notes
- Forward-only. This evolves persisted client state; you never roll a user’s storage back, so there are no down evolutions.
- Synchronous. Persisted-state evolutions are pure shape transforms. Keeping
runsync means it fits synchronous read paths. Need async evolutions? Open an issue - an async variant is ready to build if there is demand. - Result over throw. A newer-than-expected stored value is expected control flow, not an exception. Returning a discriminated result makes handling every reason compiler-enforced.