The pain this chapter solves
You serialize your theme to JSON, store it in a database, load it back six months later, and discover a field is missing or a value is the wrong type. Nothing told you it was broken until a component crashed.
Chapter 10
Validation
Why validation matters
generateTheme() always produces a well-typed, correct GeneratedTheme object. That guarantee holds for the lifetime of that function call — at build time, on the server, in your CI pipeline.
But themes do not always stay in memory. They get:
- Serialized to JSON and stored in a database
- Passed through an API endpoint
- Saved to
localStoragefor offline use - Exported as config files and committed to a repo
- Shared across microservices as theme contracts
- Loaded from user-uploaded files
At any of those boundaries, the type guarantee breaks. JSON has no schema enforcement. A database field can be null. An API consumer can send anything. A config file can be hand-edited incorrectly.
parseThemeJSON() re-establishes the guarantee at ingestion points.
Basic usage
import { parseThemeJSON } from 'salt-theme-gen';
const raw = JSON.parse(savedThemeString);
try {
const theme = parseThemeJSON(raw); // GeneratedTheme — validated
// theme is fully typed and safe to use
} catch (err) {
console.error('Invalid theme data:', err.message);
}
parseThemeJSON() returns GeneratedTheme directly or throws an error if the data is invalid:
// parseThemeJSON returns GeneratedTheme directly or throws
// Return type: GeneratedTheme
// On invalid input: throws an Error with a descriptive message
What gets validated
parseThemeJSON() checks every field in the GeneratedTheme structure:
Structural checks:
lightanddarkkeys exist and are objects- All 14 mode fields are present (
mode,colors,palettes,surfaceElevation,spacing,radius,fontSizes,iconSizes,sizeMap,dimensions,fontLevel,states,accessibility,apca) - All 23 color tokens are present in
colors - All 8 intents are present in
states, each with all 4 state keys - All 4 elevation levels are present in
surfaceElevation - All 7 spacing steps, 7 radius steps, 7 font size steps are present
- All 25 WCAG accessibility entries are present, all 25 APCA entries are present
Value checks:
- Color values are valid CSS color strings (hex, oklch, rgb, hsl)
- Spacing and radius values are positive numbers
- Font size values are positive numbers
- Accessibility ratio is a positive number
- Accessibility level is
'AAA','AA', or'fail'
The round-trip pattern
The most common use: generate once, store, validate on load.
At build time or server startup:
import { generateTheme } from 'salt-theme-gen';
import fs from 'node:fs';
const theme = generateTheme({ preset: 'ocean' });
fs.writeFileSync('theme.json', JSON.stringify(theme, null, 2));
At runtime, before using:
import { parseThemeJSON } from 'salt-theme-gen';
import fs from 'node:fs';
const raw = JSON.parse(fs.readFileSync('theme.json', 'utf8'));
let theme: GeneratedTheme;
try {
theme = parseThemeJSON(raw);
} catch (err) {
throw new Error(`Invalid theme.json: ${err.message}`);
}
export { theme };
This pattern is safe across library version upgrades. If a new version of salt-theme-gen adds required fields, parseThemeJSON() will report which fields are missing — you re-generate and re-save.
Validating themes from an API
If your backend serves theme configuration to a frontend client:
API endpoint (server):
import { generateTheme } from 'salt-theme-gen';
app.get('/api/theme', (req, res) => {
const { preset = 'ocean' } = req.query;
const theme = generateTheme({ preset: String(preset) });
res.json(theme);
});
Client-side fetch with validation:
import { parseThemeJSON } from 'salt-theme-gen';
async function fetchTheme(): Promise<GeneratedTheme> {
const response = await fetch('/api/theme?preset=ocean');
const raw = await response.json();
try {
return parseThemeJSON(raw);
} catch (err) {
throw new Error(`Theme API returned invalid data: ${err.message}`);
}
}
Without validation, a network error, a proxy mangling the response, or a server-side bug could silently return malformed data that crashes your component tree somewhere deep in a render.
Validating user-provided themes
If your product allows users to upload or configure custom themes:
import { parseThemeJSON } from 'salt-theme-gen';
function handleThemeUpload(file: File) {
const reader = new FileReader();
reader.onload = (e) => {
let raw: unknown;
try {
raw = JSON.parse(e.target?.result as string);
} catch {
showError('Invalid JSON file.');
return;
}
try {
const theme = parseThemeJSON(raw);
applyTheme(theme);
} catch (err) {
showError(`Invalid theme file: ${err.message}`);
}
};
reader.readAsText(file);
}
The errors array gives you user-facing messages. The first error is usually sufficient to show — listing all 25 missing fields when the file is completely wrong is not helpful.
Validating themes in localStorage
Storing the active theme in localStorage for instant load:
import { generateTheme, parseThemeJSON } from 'salt-theme-gen';
import type { GeneratedTheme } from 'salt-theme-gen';
const STORAGE_KEY = 'app-theme';
function saveTheme(theme: GeneratedTheme): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(theme));
}
function loadTheme(): GeneratedTheme {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return generateTheme({ preset: 'ocean' }); // fallback
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return generateTheme({ preset: 'ocean' }); // corrupted JSON fallback
}
try {
return parseThemeJSON(parsed);
} catch {
// Stale or incompatible theme — regenerate and overwrite
const fresh = generateTheme({ preset: 'ocean' });
saveTheme(fresh);
return fresh;
}
}
This handles three failure modes: missing key, corrupted JSON, and schema mismatch from a library upgrade — all without crashing.
Using errors for debugging
The thrown error message is human-readable and identifies the failing field path and what was expected:
try {
parseThemeJSON({ light: { colors: { primary: 123 } } });
} catch (err) {
console.error(err.message);
// "theme: light.colors.primary: expected a CSS color string, got number"
}
During development, log the full error message. In production, log a summary and the message from the caught error.
try {
const theme = parseThemeJSON(raw);
// use theme
} catch (err) {
console.error('Theme validation failed:', err.message);
}
parseThemeJSON vs TypeScript types
TypeScript types are erased at runtime. This code compiles but fails at runtime:
// Looks type-safe. Is not.
const theme = JSON.parse(savedString) as GeneratedTheme;
theme.light.colors.primary; // may throw if light is undefined
parseThemeJSON() is the runtime equivalent of the TypeScript type — it checks the shape at the moment you actually need the guarantee. Use both together:
import { parseThemeJSON } from 'salt-theme-gen';
import type { GeneratedTheme } from 'salt-theme-gen';
function loadTheme(raw: unknown): GeneratedTheme {
return parseThemeJSON(raw); // throws if invalid — let the caller handle the error
}
Checking library compatibility
parseThemeJSON() validates the current library’s schema. If you serialized a theme with salt-theme-gen@1.0.0 and now run salt-theme-gen@1.2.0 which added new fields, validation will fail on the old data — which is the correct behavior.
The fix is always: regenerate and re-save.
// After a library upgrade, re-generate all stored themes
const themes = await db.query('SELECT id, config FROM saved_themes');
for (const row of themes) {
try {
parseThemeJSON(JSON.parse(row.config));
} catch {
// Re-generate from the stored preset name
const fresh = generateTheme({ preset: row.preset });
await db.query('UPDATE saved_themes SET config = ? WHERE id = ?',
[JSON.stringify(fresh), row.id]);
}
}
Run this as a migration script after each salt-theme-gen upgrade that changes the schema.
Zod users: parseThemeJSON() is implemented in plain TypeScript — no Zod dependency. The error messages are human-readable path-based strings (e.g. theme.light.colors.primary: expected CSS color string, got number). You do not need to write your own Zod schema for GeneratedTheme — parseThemeJSON() is the canonical validator.