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:
- Validate Data: Ensure incoming data (from APIs, local storage, or user input) matches our expectations at runtime.
- Infer Types: Automatically generate TypeScript types from the schema, ensuring our code and our validation are always in sync.
- 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:
- Frontend: Validates data before saving it to the local Guest Mode database (Dexie).
- 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.