Building a Nutritional Tracker: Part 3 - Data Validation with Zod
Part 3: Data Validation with Zod — Nutrition Tracker
Introduction
In the previous chapter, we set up the testing environment. Now it’s time to ensure our data is safe and correct: that is, validate everything before saving or processing.
Why use Zod for validation?
- Allows you to define rules and types in one place.
- Detects incorrect data at runtime before it causes errors.
- Automatically generates TypeScript types from schemas (no duplication).
- Validations and errors are clear and centralized.
Model structure
We start from our central model:
Each field requires specific rules. For example:
amountmust be a number greater than zero.dateandtimemust follow the correct format.unit,mealType, andsweetenermust be one of the values listed in their enum.
Basic Zod schema
import { z } from 'zod'
export const RegisterSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
userName: z.string().min(1),
food: z.string().min(1),
amount: z.number().positive(),
unit: z.enum([
'gr',
'ml',
'unit',
'portion',
'small-portion',
'large-portion',
]),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
time: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM
mealType: z.enum(['breakfast', 'lunch', 'snack', 'dinner', 'collation']),
createdAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
sweetener: z.enum(['sugar', 'sweetener']).optional().nullable(),
notes: z.string().optional(),
})Automatic type inference
export type Register = z.infer<typeof RegisterSchema>This means TypeScript understands your data types directly from the schema. If you change the schema, the type updates effortlessly.
Example: Practical validation
Suppose you have user input data:
const data = {
id: 'not-a-uuid',
userId: '1234',
userName: '',
food: '',
amount: -5,
unit: 'litros',
date: '11-2025-11',
time: '8:30am',
mealType: 'brunch',
createdAt: '2025-11-11',
}When validating with Zod (using .safeParse()):
const result = RegisterSchema.safeParse(data)
if (!result.success) {
// You can iterate over errors to show messages for each field
result.error.issues.forEach((issue) => {
console.log(`Field: ${issue.path[0]} - ${issue.message}`)
})
}Validation flow diagram
graph TD
A[User data] --> B{RegisterSchema.safeParse}
B -- valid --> C[Process normally]
B -- invalid --> D[Report errors]
D --> E[Display error messages in the form]
Additional advantages
- You can use enums to keep menus and selects consistent.
- Error messages generated by Zod are clear and easy to display in the UI.
- It’s easy to extend the schema for new fields in the future.
Unit tests (practical example)
import { describe, expect, it } from 'vitest'
describe('RegisterSchema', () => {
it('accepts valid record', () => {
const valid = {
id: 'a1b2c3d4-e5f6-7890-abcd-1234567890ab',
userId: 'b2c3d4e5-f6a7-8901-bcde-2345678901bc',
userName: 'Juan',
food: 'Apple',
amount: 1,
unit: 'unit',
date: '2025-11-11',
time: '08:30',
mealType: 'breakfast',
createdAt: '2025-11-11T08:35:00Z',
sweetener: null,
notes: 'Fresh',
}
expect(() => RegisterSchema.parse(valid)).not.toThrow()
})
it('rejects negative amount', () => {
const invalid = { ...valid, amount: -2 }
expect(() => RegisterSchema.parse(invalid)).toThrow()
})
it('rejects incorrect date format', () => {
const invalid = { ...valid, date: '11-11-2025' }
expect(() => RegisterSchema.parse(invalid)).toThrow()
})
})What’s next?
You now have robust validation in your model. The next step is to create the persistence layer: save and retrieve validated records, handling errors and edge cases.
Continue reading: Part 4: Persistence Layer Implementation → How to save to localStorage, retrieve data, and maintain error tolerance.