Zod + defensive parsing in a local-first app: make your offline data trustworthy
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
PainEntryinterface fromsrc/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:
- Backwards‑compatible IDs –
idis a union ofstring | numberso older stored data doesn’t explode. - Timestamp validation that fails closed –
timestampmust be a parseable date string; otherwise it’s invalid. No “best effort” guessing. - 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
PainEntryshape?” - “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
safeParsefor UI (gentle error handling). - Use
parsefor 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
superRefinefor 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