Professional Validation with Zod

Professional Validation with Zod

Welcome to the next stage of professionalizing your Task Manager API. At this point, your backend is already functional and secure — it features a well-structured service layer, clear routes, and proper logging.
However, there’s still one critical piece missing for production-grade reliability: data validation.

That’s where Zod comes in. Zod is a TypeScript-first validation library that allows you to describe what valid data looks like — and automatically ensures that every incoming request matches that structure.

With Zod:

  • You can trust that all data reaching your service layer is correctly shaped.
  • Validation rules become clear, consistent, and type-safe.
  • Error messages are standardized and integrate seamlessly with your existing err() response helper.
  • Routes stay concise — validation becomes a single line of code instead of multiple conditionals.

By integrating Zod, your Remix API gains type safety, runtime validation, and predictable error handling — all from one unified source of truth.

How Zod Fits Into the Current Architecture

Here’s how Zod complements the architecture you’ve already built:

  • Routes: Parse JSON, validate with Zod schemas, and return responses.
  • Validation Layer (taskSchemas.ts): Defines what valid task data looks like for each HTTP method.
  • Service Layer (taskService.ts): Operates only on trusted, pre-validated data.
  • Response Layer (responses.ts): Translates validation results into consistent JSON structures.
  • Logging (withLogging.server.ts): Records success or failure outcomes from Zod-based validation.

Together, these layers form a professional validation pipeline where:

  • Bad data is caught early.
  • Clients receive consistent, structured feedback.
  • Routes remain clean and easy to maintain.

Understanding the Schemas: `taskSchemas.ts`

Your validation rules live in a single file: app/lib/validation/taskSchemas.ts.

import { z } from "zod";

export const taskCreateSchema = z.object({
  title: z.string().min(1, "'title' cannot be empty"),
  description: z.string().optional(),
  completed: z.boolean().optional(),
  dueDate: z.string().optional()
});

export const taskPutSchema = z.object({
  title: z.string().min(1),
  description: z.string().optional(),
  completed: z.boolean(),
  dueDate: z.string().optional()
});

export const taskPatchSchema = taskCreateSchema.partial();

POST allows minimal input and lets the backend apply defaults (e.g., completed: false). PUT represents a full replacement, so required fields like completed must be included to avoid accidentally overwriting state by omission.

How Each Schema Works

taskCreateSchema (POST)
Used for creating new tasks. It requires a non-empty title but allows optional description, completed, and dueDate fields.
This flexibility lets users send minimal data while your service layer fills in sensible defaults.

taskPutSchema (PUT)
Used for full replacements. All primary fields like title and completed are required.
This ensures PUT requests always replace the entire object, keeping the data structure consistent.

taskPatchSchema (PATCH)
Built with taskCreateSchema.partial(), which makes all fields optional.
This allows clients to update only what they need — for example, sending { "completed": true } to mark a task as done.

Why This Structure Is Effective

Each schema reflects the behavior of its HTTP method:

  • POST → Create a new task with minimal valid input.
  • PUT → Replace an existing task entirely (all fields required).
  • PATCH → Update part of a task (fields optional).

This structure makes validation both technically correct and easy to understand at a glance. Anyone reading the code can immediately see what kind of data each endpoint expects.

Using Zod in the Routes

Let’s see how Zod works within your Remix API routes.
Here’s an example of integrating validation in app/routes/api.tasks.tsx for POST /api/tasks:

let body: unknown;
try {
  body = await request.json();
} catch {
  return err("Invalid JSON body", 400);
}

const parsed = taskCreateSchema.safeParse(body);
if (!parsed.success) {
  const issues = parsed.error.issues.map((issue) => issue.message);
  return err(issues, 400);
}

const created = createTask({
  title: parsed.data.title,
  description: parsed.data.description,
  completed: parsed.data.completed,
  dueDate: parsed.data.dueDate
});

return ok(created, 201);

What Happens Step-by-Step

  1. The request body is parsed as JSON — this is the raw input from the client.
  2. Zod validates it using taskCreateSchema.parse(body).
    • If it matches the schema, a strongly typed object (parsed) is returned.
    • If it fails, Zod throws an error containing detailed issue messages.
  3. On success, your code calls createTask() with safe, validated data.
  4. On failure, the error is caught, the issues are extracted, and the route returns err(issues, 400) — resulting in a clear, structured response.

Example: Invalid Request

Input:

{ "title": "" }

Response:

{
  "status": "error",
  "error": {
    "message": "'title' cannot be empty"
  }
}

Console log:

[2025-10-26T11:22:10.400Z] FAIL POST /api/tasks (2ms) [400] -> 'title' cannot be empty

Using Zod in Task Routes with IDs

You can use the same pattern for routes like PUT and PATCH in app/routes/api.tasks.$id.tsx.

Example: PUT /api/tasks/:id

const parsed = taskPutSchema.parse(body);
const updated = replaceTask(id, {
  title: parsed.title,
  description: parsed.description,
  completed: parsed.completed,
  dueDate: parsed.dueDate
});
return updated ? ok(updated) : err("Task not found", 404);

What it does:
The taskPutSchema ensures all fields are present and correctly typed.
This guarantees that your service layer receives complete data for full replacements.

Example: PATCH /api/tasks/:id

const parsed = taskPatchSchema.parse(body);
const updated = patchTask(id, {
  title: parsed.title,
  description: parsed.description,
  completed: parsed.completed,
  dueDate: parsed.dueDate
});
return updated ? ok(updated) : err("Task not found", 404);

Here, taskPatchSchema ensures that any fields included are valid, while missing fields are simply ignored.
This makes partial updates safe and predictable without requiring additional conditional checks.

Why Zod Works So Well Here

Zod integrates seamlessly with your existing architecture because:

  • Routes stay simple: Each handler validates input with one line: schema.parse().
  • Error handling is automatic: Zod’s issues map directly into your err() helper.
  • Logs capture everything: Validation failures appear in structured logs for easy debugging.
  • Consistent backend feedback: The backend’s validation messages are consistently surfaced in API responses and logs.

With Zod, you no longer have to write repetitive checks or format manual error strings — the library takes care of that while keeping everything consistent.

Example Log Outputs

Here’s how validation activity looks in your logs:

Valid Requests

[2025-10-26T12:01:02.502Z] START POST /api/tasks
[2025-10-26T12:01:02.503Z] SUCCESS POST /api/tasks (1ms)

Missing Required Fields

[2025-10-26T12:02:15.301Z] START POST /api/tasks
[2025-10-26T12:02:15.303Z] FAIL POST /api/tasks (2ms) [400] -> 'title' cannot be empty

Wrong Data Types

[2025-10-26T12:03:41.701Z] START PUT /api/tasks/:id
[2025-10-26T12:03:41.704Z] FAIL PUT /api/tasks/:id (3ms) [400] -> Expected boolean, received string

Each message is clear, timestamped, and includes both the HTTP method and route — giving you all the context needed for fast troubleshooting.

Summary

In this lesson, you learned how to bring professional-grade validation to your Remix API using Zod.
Zod acts as a contract between the client and backend — defining valid data, enforcing it at runtime, and delivering clean, consistent feedback when something’s wrong.

By using Zod:

  • Your routes stay concise and reliable.
  • Your validation rules live in one place (taskSchemas.ts).
  • Your responses and logs remain consistent across endpoints.
  • Your backend becomes safer, clearer, and easier to maintain.

You now have a major piece of a more production-ready backend — predictable, secure, and professionally validated data handling.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal