Project setup
What we are building
Now that you understand what schemas are and how parse and safeParse work, let’s put it into practice. We are going to build a contact form API throughout this course. Users submit their name, email, and message. Simple on the surface, but it will let us exercise every Zod concept: string validation, optional fields, error formatting, and request validation with Hectoday HTTP.
By the end of the course, every field will be validated with Zod before anything touches the database. But first, let’s see what happens when you skip validation entirely.
Create the project
mkdir zod-contacts
cd zod-contacts
npm init -y
npm install @hectoday/http zod srvx
npm install -D typescript @types/node tsx Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
} Add "type": "module" and a dev script to package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts"
}
} To start the server at any point during this course:
npm run dev The server starts on http://localhost:3000. You can test it with curl or any HTTP client. tsx watch restarts automatically when you save a file.
Setting up the data store
We need somewhere to keep contacts. A simple in-memory array is enough for this course. It lets us focus entirely on Zod without any database setup.
// src/db.ts
export interface Contact {
id: string;
name: string;
email: string;
phone: string | null;
subject: string;
message: string;
createdAt: string;
}
export const contacts: Contact[] = []; The phone field is nullable, meaning it can be left out. The subject field will have a default value of 'general' once we add validation. These two patterns will map directly to Zod concepts we will learn later: .optional() and .default(). Keep them in the back of your mind.
The app without validation
Here is the interesting part. Let’s write the API without any validation at all:
// src/app.ts
import { setup, route } from "@hectoday/http";
import { contacts } from "./db.js";
export const app = setup({
routes: [
route.post("/contacts", {
resolve: async (c) => {
// No validation! Whatever the client sends goes straight in.
const body = c.input.body as any;
const contact = {
id: crypto.randomUUID(),
name: body.name,
email: body.email,
phone: body.phone ?? null,
subject: body.subject ?? "general",
message: body.message,
createdAt: new Date().toISOString(),
};
contacts.push(contact);
return Response.json({ status: "received" }, { status: 201 });
},
}),
route.get("/contacts", {
resolve: () => {
return Response.json(contacts);
},
}),
],
}); // src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
serve({ fetch: app.fetch, port: 3000 }); Start the server with npm run dev and try it:
# Send valid data
curl -X POST http://localhost:3000/contacts \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "[email protected]", "message": "Hello!"}'
# List contacts
curl http://localhost:3000/contacts This works. You can start the server, send a POST request with valid data, and it gets stored. Everything seems fine.
But here is the problem: this code trusts the client completely. What do you think happens if someone sends { name: 42 }? It stores 42 as the name. What about { email: "not-an-email" }? Goes right in. What about an empty body? Everything ends up as undefined.
None of those should be allowed. But without validation, the API happily accepts all of it. Bad data goes straight into the store, and you only find out something is wrong when it breaks downstream, maybe when you try to send an email to "not-an-email" and wonder why it fails.
This is exactly the problem from the first lesson, but now you can see it in a real project. Over the next several lessons, we will learn the Zod features needed to lock this down, one piece at a time. By the end, every field will be validated before it ever reaches the database.
Exercises
Exercise 1: Start the server. POST valid data. Verify it appears in the database.
Exercise 2: POST { name: 42 }. Observe what happens. No validation error, just bad data in the database.
Exercise 3: POST an empty body. Observe the database error. This should be a clean 400 validation error instead.
Why is validating input before inserting into the database important?