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 localStorage for 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:

  • light and dark keys 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 GeneratedThemeparseThemeJSON() is the canonical validator.