hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Validation for beginners with Zod

What is Zod

  • The problem with untyped data
  • Your first schema
  • Project setup

Primitive types

  • Strings
  • Numbers
  • Booleans, dates, and literals

Objects and arrays

  • Objects
  • Nested objects
  • Arrays
  • Unions and discriminated unions

Transforms and refinements

  • Transforms
  • Refinements
  • Preprocessing and coercion

Composing schemas

  • Pick, omit, and extend
  • Merge and intersection
  • Reusable schemas

Zod in practice

  • Validating API requests
  • Error formatting
  • Inferring TypeScript types
  • Cheatsheet and capstone

Strings

Most of your data is strings

Names, emails, URLs, passwords, messages, IDs. Look at any web application and you will find that the majority of user input is text. In the last section, we learned that z.string() is a schema that validates a value is a string. But just knowing something is a string is rarely enough. You need to know: is it long enough? Is it a valid email? Does it have trailing whitespace that will cause problems later?

This is where refinements come in. A refinement is a method you chain onto a schema to add an extra constraint. .min(1), .max(100), .email(), .url() are all refinements. They do not change the base type. They narrow what values are accepted. A z.string().email() is still a string schema, it just rejects strings that do not look like email addresses.

This lesson covers all the string refinements you will use regularly.

Length refinements

import { z } from "zod/v4";

z.string().min(1); // at least 1 character (not empty)
z.string().max(100); // at most 100 characters
z.string().length(5); // exactly 5 characters
z.string().min(1).max(50); // between 1 and 50 characters

min(1) is the refinement you will use more than any other. It rejects empty strings. Without it, "" is perfectly valid:

Code along
z.string().parse(""); // passes (empty string is still a string)
z.string().min(1).parse(""); // fails: "String must contain at least 1 character(s)"

Think about it: an empty string is technically a string. But is an empty name valid user input? Almost never. Always use min(1) for required string fields.

Format validators

Zod includes built-in validators for formats you will encounter constantly:

z.string().email(); // must be a valid email address
z.string().url(); // must be a valid URL
z.string().uuid(); // must be a valid UUID (v4)
z.string().datetime(); // must be a valid ISO 8601 datetime

Let’s see them in action:

z.string().email().parse("[email protected]"); // passes
z.string().email().parse("not-an-email"); // fails: "Invalid email"

z.string().url().parse("https://example.com"); // passes
z.string().url().parse("example.com"); // fails: "Invalid url" (no protocol)

z.string().uuid().parse("550e8400-e29b-41d4-a716-446655440000"); // passes
z.string().uuid().parse("not-a-uuid"); // fails: "Invalid uuid"

Notice that "example.com" fails URL validation because it has no protocol. Zod requires a full URL with http:// or https://. These built-in validators handle the tricky edge cases so you do not have to write regex for common formats.

Regex for everything else

For patterns that the built-in validators do not cover, there is .regex():

// Only alphanumeric and hyphens (slug format)
z.string().regex(/^[a-z0-9-]+$/);

// Starts with a letter
z.string().regex(/^[a-zA-Z]/);

// Phone number format (simple US)
z.string().regex(/^\d{3}-\d{3}-\d{4}$/);
z.string()
  .regex(/^[a-z0-9-]+$/)
  .parse("hello-world"); // passes
z.string()
  .regex(/^[a-z0-9-]+$/)
  .parse("Hello World"); // fails: "Invalid"

Use built-in validators when they exist (email, URL, UUID). Reach for regex when you need something specific to your domain.

String transforms

Sometimes you do not just want to validate a string, you want to clean it up. These methods modify the string after validation:

z.string().trim(); // removes leading/trailing whitespace
z.string().toLowerCase(); // converts to lowercase
z.string().toUpperCase(); // converts to uppercase

Transforms run after type validation. trim() first confirms the value is a string, then removes the whitespace:

z.string().trim().parse("  Alice  "); // returns "Alice"
z.string().trim().min(1).parse("   "); // fails: after trimming, empty string fails min(1)

That second example is important. What do you think happens if a user types three spaces into a “name” field? Without trim(), " " passes min(1) because it has three characters. They just happen to be whitespace. With trim().min(1), the whitespace gets removed first, leaving an empty string, which correctly fails.

[!TIP] Use .trim().min(1) for most required text fields. This catches inputs that are only whitespace, which look non-empty but contain no real content.

Chaining validators

All validators chain together. They run left to right:

const NameSchema = z.string().trim().min(1).max(100);
const EmailSchema = z.string().trim().toLowerCase().email();
const SlugSchema = z
  .string()
  .trim()
  .min(1)
  .max(50)
  .regex(/^[a-z0-9-]+$/);

For EmailSchema, the chain works like this: first trim the whitespace, then convert to lowercase, then check if it is a valid email. If any step fails, the chain stops and the error is reported for that step.

This means " [email protected] " goes through trim() (becomes "[email protected]"), then toLowerCase() (becomes "[email protected]"), then email() (passes). The output is a clean, normalized email address.

Applying to the contact form

Let’s bring this back to our project. Here is what the contact form schema looks like now that we know about string constraints:

const ContactSchema = z.object({
  name: z.string().trim().min(1).max(100),
  email: z.string().trim().toLowerCase().email(),
  message: z.string().trim().min(10).max(5000),
});

The name must be 1 to 100 characters after trimming. The email is trimmed, lowercased, and validated. The message must be at least 10 characters (to reject trivial submissions like “hi”) and at most 5000 characters. Every string is trimmed so whitespace-only inputs get caught.

This is a huge improvement over the unvalidated version from the project setup lesson. But we are only handling strings so far. What about numbers? That is up next.

Exercises

Exercise 1: Create a schema for a username: 3-20 characters, alphanumeric and underscores only. Test with valid and invalid inputs.

Exercise 2: Create an email schema with trim and toLowerCase. Parse " [email protected] ". Verify the output is "[email protected]".

Exercise 3: Create a schema that rejects whitespace-only strings. Parse " " and verify it fails.

Why should you use .trim().min(1) instead of just .min(1) for required string fields?

← Project setup Numbers →

© 2026 hectoday. All rights reserved.