Building a Nutritional Tracker: Part 3 - Data Validation with Zod

2 min

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:

Record=(id,userId,userName,food,amount,unit,date,time,mealType,sweetener,notes,createdAt)\text{Record} = (id, userId, userName, food, amount, unit, date, time, mealType, sweetener, notes, createdAt)

Each field requires specific rules. For example:

  • amount must be a number greater than zero.
  • date and time must follow the correct format.
  • unit, mealType, and sweetener must 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.


Series Navigation

Additional Resources