Skip to content

Schema Validation with Zod

What is this?

We use Zod as our single source of truth for data validation. This note explains how Zod works, why we use it in a shared package, and breaks down the MealSchema that powers ChatKcal.

1. What is Zod?

Zod is a TypeScript-first schema declaration and validation library. Unlike standard TypeScript types (which vanish at runtime), Zod schemas:

  1. Validate Data: Ensure incoming data (from APIs, local storage, or user input) matches our expectations at runtime.
  2. Infer Types: Automatically generate TypeScript types from the schema, ensuring our code and our validation are always in sync.
  3. Transform Data: Clean up or format data (e.g., rounding numbers or setting defaults) during the validation process.

2. The MealSchema Breakdown

Located in shared/src/schemas/mealSchema.ts, this schema defines a "Meal" in our system.

The Nutrient Helper

We use a reusable nutrientSchema to ensure consistency across calories, protein, carbs, and fat.

const roundToTwo = (val: number) => Math.round(val * 100) / 100;

const nutrientSchema = z.number()
  .min(0, "Must be non-negative")
  .transform(roundToTwo);
  • .min(0): Rejects negative numbers.
  • .transform(): Automatically rounds any input to 2 decimal places.

The Schema Structure

Field Validation Purpose
sk z.string().uuid() Unique identifier (required for DB, optional for new items).
userDate regex(/^\d{4}-\d{2}-\d{2}$/) Strict YYYY-MM-DD format.
createdAt z.string().datetime() ISO 8601 timestamp.
mealSummary z.string().min(1) Prevents empty meal names.
emoji .default('🍽️') Provides a fallback if no emoji is provided.
extendedNutrients z.record(z.string(), nutrientSchema) A flexible dictionary for extras like Fiber or Sodium.

3. The "Tolerant Reader" Pattern

Crucially, we do not use .strict() on our objects.

  • Why? In a distributed system (Frontend + Backend), one side might be updated before the other.
  • The Benefit: If the Frontend starts sending a new field (e.g., waterContent), a "Strict" Backend would crash with a 400 error. By default, Zod strips unknown fields, allowing for "Forward Compatibility."

4. Examples in Action

Valid Input

This input will pass validation, and the calories will be rounded automatically.

const result = MealSchema.safeParse({
  userDate: "2026-01-18",
  mealSummary: "Avocado Toast",
  calories: 250.3456, // Will be transformed to 250.35
  protein: 5,
  carbs: 30,
  fat: 15
});

if (result.success) {
  console.log(result.data.calories); // 250.35
}

Invalid Input

These will trigger clear error messages.

MealSchema.safeParse({
  userDate: "Jan 18, 2026", // FAIL: Invalid format
  mealSummary: "",          // FAIL: Too short
  calories: -10             // FAIL: Negative number
});

5. Why a "Shared" Package?

We maintain this schema in the @chatkcal/shared package so that:

  1. Frontend: Validates data before saving it to the local Guest Mode database (Dexie).
  2. Backend: The Import Lambda uses the exact same rules to validate data before it hits DynamoDB.

SAM Build Gotcha

AWS SAM runs builds in an isolated sandbox. We use "Just-in-Time Vendoring" (managed via Taskfile.yml) to copy the shared code into the Lambda's directory before deployment, ensuring the schema is available at runtime.