Zod + defensive parsing in a local-first app: make your offline data trustworthy

Published: (February 5, 2026 at 12:14 PM EST)
3 min read
Source: Dev.to

Source: Dev.to

Offline‑first changes what “input validation” means

Most apps validate only the HTML form you just submitted.
A local‑first app has more input surfaces:

  • persisted state blobs (rehydration)
  • IndexedDB rows from older versions
  • import/restore flows
  • test fixtures that accidentally drift

If you treat those as “trusted because they’re local”, you eventually ship a version that:

  • crashes on someone’s long‑lived data, or
  • loads but silently misinterprets fields.

Pain Tracker draws a clear line:

  • TypeScript types are compile‑time truth.
  • Zod schemas are runtime truth.

The project makes that explicit in src/types.ts:

  • It re‑exports the canonical PainEntry interface from src/types/index.ts.
  • It re‑exports Zod schemas from src/types/pain-entry.ts.

(And it calls out that schemas are for runtime validation only.)

PainEntrySchema

The schema lives in src/types/pain-entry.ts. A few choices worth copying:

  1. Backwards‑compatible IDsid is a union of string | number so older stored data doesn’t explode.
  2. Timestamp validation that fails closedtimestamp must be a parseable date string; otherwise it’s invalid. No “best effort” guessing.
  3. Defaults for optional sections – many nested objects use .default(...) so missing sections don’t force every caller to rebuild the full shape.

Defaults are not a substitute for validation; they’re a way to make valid‑but‑incomplete inputs land in a stable, predictable shape.

Pain Tracker separates:

  • “Is this a valid PainEntry shape?”
  • “Is this a valid new entry?”

The create schema is built like this:

// src/types/pain-entry.ts
import { z } from "zod";

export const PainEntrySchema = z.object({
  id: z.union([z.string(), z.number()]),
  timestamp: z.string().refine((s) => !isNaN(Date.parse(s)), {
    message: "Invalid timestamp",
  }),
  // …other fields…
});

export const CreatePainEntrySchema = PainEntrySchema
  .omit({ id: true, timestamp: true })
  .superRefine((data, ctx) => {
    if (!data.locations?.length) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "At least one location must be selected",
      });
    }
  });

That rule is tested directly in src/types/pain-entry.test.ts.

Validation patterns

  • Keep the “shape” schema stable for migrations / imports.
  • Use stricter schemas for user‑facing creation paths.
  • Use safeParse for UI (gentle error handling).
  • Use parse for invariants, boundary checks, or tests.

UI example

// src/components/pain-tracker/PainEntryForm.tsx
import { CreatePainEntrySchema } from "../../types/pain-entry";

const result = CreatePainEntrySchema.safeParse(formData);
if (!result.success) {
  // display the first issue message
}

Exported helpers

// src/types/pain-entry.ts
export const validatePainEntry = (data: unknown) => PainEntrySchema.parse(data);
export const safeParsePainEntry = (data: unknown) => PainEntrySchema.safeParse(data);

Keep schemas “boring” (future you will thank you)

A few rules that keep schema‑first apps from becoming unmaintainable:

  • Prefer explicit fields over “catch‑all” objects.
  • Use superRefine for cross‑field logic (e.g., “must include at least one location”).
  • Add tests when you add a rule.
  • Treat runtime validation as part of your migration strategy, not just form UX.

Next in the series

  • Part 5 – Trauma‑informed UX + accessibility as architecture
  • Previous: Part 3 – Service workers that don’t surprise you

Support the project

  • Sponsor the build
  • Star the repo
Back to Blog

Related posts

Read more »