We use cookies to ensure you get the best experience on our website. Read our privacy policy

Skip to main content
Typesafe Local Storage illustration showing code and data structures
Home Blog Web Dev

Build Typesafe Local Storage in JavaScript

Stop struggling with unreliable local storage data. This guide shows you how to create a typesafe local storage solution that prevents common errors and makes your web applications more robust.

Published on:
Clock Time to complete: 15 minutes

Browser local storage is powerful but risky. The simple key-value API makes it easy to store strings, but complex data requires manual serialization and deserialization. Without type checking, you risk runtime errors when data doesn’t match your expectations.

Common local storage problems include:

  • Storing complex objects without proper serialization
  • Retrieving data with unexpected structure
  • Missing properties in stored objects
  • No schema validation for stored data

A typesafe approach solves these issues by enforcing data structure at compile time.

First, define a schema that describes all possible data structures you’ll store:

src/schemas/local-data.ts
import { z } from 'astro/zod';
// Define schemas for different types of data
export const ZContactFormSchema = z.object({
name: z.string(),
email: z.string().email(),
message: z.string(),
submitted: z.boolean().optional(),
});
export const ZAnalyticsSchema = z.object({
pageViews: z.record(z.string(), z.number()),
lastVisit: z.string(), // ISO date string
});
// Create the main schema object with all possible storage keys
export const ZLocalDataSchemas = z.object({
['contact-form']: ZContactFormSchema,
['analytics']: ZAnalyticsSchema,
['color-picker-history']: z.array(z.string()),
// Add more schemas as needed
});
// Export the inferred TypeScript type
export type LocalDataSchemas = z.infer<typeof ZLocalDataSchemas>;

This schema creates a contract for your data. Now TypeScript knows exactly what structure to expect for each storage key.

Limited Time Launch Sale

Want to skip the setup? Our Astro starter template includes a ready-to-use typesafe local storage system with pre-built schemas!

GET 60% OFF!

Next, create a utility that enforces these types when reading from or writing to local storage:

src/lib/client/storage.ts
import { CONFIG } from '../../constants';
import type { LocalDataSchemas } from '../../schemas/local-data';
// Prefix keys to avoid collisions with other apps
const dataKey = (key: string) => `${CONFIG.APP_NAME}:${key}`;
// JSON-safe type definitions
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonObject | JsonArray;
// Convert non-JSON-safe types to string
type JsonSafe<T> = {
[P in keyof T]: T[P] extends JsonValue ? T[P] : T[P] extends Date ? string : T[P] extends object ? { [K in keyof T[P]]: JsonSafe<T[P][K]> } : string;
};
// Get data with type safety
export const getData = <T extends keyof LocalDataSchemas, R = LocalDataSchemas[T]>(location: T): Partial<JsonSafe<R>> | undefined => {
let localData: Partial<JsonSafe<R>> | undefined;
try {
const temp = localStorage.getItem(dataKey(location));
if (temp) localData = JSON.parse(temp) as JsonSafe<R>;
} catch (e) {
console.error(`Error loading data for ${location}:`, e);
}
return localData;
};
// Set data with type safety
export const setData = <T extends keyof LocalDataSchemas, R = LocalDataSchemas[T]>(location: T, data: Partial<JsonSafe<R> | R>): void => {
try {
localStorage.setItem(dataKey(location), JSON.stringify(data));
} catch (e) {
console.error(`Error saving data for ${location}:`, e);
}
};
// Delete data
export const deleteData = <T extends keyof LocalDataSchemas>(location: T): void => {
try {
localStorage.removeItem(dataKey(location));
} catch (e) {
console.error(`Error deleting data for ${location}:`, e);
}
};
// Example usage
import { getData, setData, deleteData } from '../lib/client/storage';
// TypeScript knows exactly what shape this data should have
const contactForm = getData('contact-form');
// TypeScript will error if you try to access a property that doesn't exist
const email = contactForm?.email; // ✓ Valid
const phone = contactForm?.phone; // ✗ Error: Property 'phone' does not exist
// TypeScript will error if you try to save data with the wrong shape
setData('contact-form', {
name: 'John Doe',
message: 'Hello world',
// TypeScript would error if we tried to add an unexpected field
});
// Delete data when no longer needed
deleteData('contact-form');

For forms, we can create a helper function that automatically saves form data to local storage:

src/lib/client/storage.ts
// Generic form data handler
export const genericSaveDataHandler = <T extends keyof LocalDataSchemas>(
key: T,
options?: {
dontSave?: (keyof LocalDataSchemas[T])[];
}
) => {
return (formData: FormData) => {
const data = getData<T>(key) || {};
if (options?.dontSave) {
options.dontSave.forEach((key) => formData.delete(String(key)));
}
const entries = Object.fromEntries(formData.entries());
const newData = {
...data,
...entries,
} as unknown as JsonSafe<LocalDataSchemas[T]>;
setData(key, newData);
};
};
  • Expiration dates: Add TTL (time-to-live) for stored data
  • Compression: Compress large data before storing
  • Migration: Handle schema changes with version tracking

By implementing typesafe local storage, you’ll catch errors at compile time rather than runtime, making your applications more reliable and easier to maintain.

Related Articles

Read more Web Dev articles
North Star Themes Logo

Subscribe to our newsletter

Get the latest AstroJS tips and tricks right to your inbox

Email: [email protected]

© 2025 North Star Themes

Web Kit Provided By North Star Themes