TypeScript Zod 4 for Beginners: Schema, z.infer, safeParse and Readable Errors
November 6, 2026
Welcome to episode 34 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! This is Part 4: validation and an HTTP server. In episode twenty nine we wrote a type guard by hand. It works, but it is long, and the type and the check can drift apart. Today we meet Zod, a library where one schema gives both. A new file called schema.ts in the tasks project. Every code block below is the exact code and output from the video, ready to copy.
- All episodes are in the TypeScript from Zero playlist.
- Versions used: Node.js 26.13.0, npm 11.20.0, TypeScript 7.0.2.
Setup
We start in the terminal and open the project in VS Code:
cd tasks
touch src/schema.ts
code .1. Install
Zod is a normal package. npm install zod, and it also brings its own types, so there is no types package to add. It is version four.
$ npm install zod
added 1 package, and audited 6 packages in 523ms
1 package is looking for funding
run `npm fund` for details
found 0 vulnerabilities2. Schema
A schema describes data. Z dot object lists the fields, and each field has its own schema: z number, z string. Z dot literal allows exactly one value. And z discriminated union takes the name of the key to look at, and the list of shapes. This is our task model from Part 3, written as a schema.
3. z.infer
And the type? Z dot infer reads the schema and gives us the matching type. There is no second copy to keep up to date. Change the schema, and the type changes with it.
4. safeParse
To check data, we call safe parse. It never throws. It returns an object with success true and the data, or success false and an error. A good task passes. For the bad one, the id is a text, and z dot prettify error turns the error into a message that a person can read, with the place of the problem.
src/check.ts:
import { z } from "zod";
import { TaskSchema } from "./schema.ts";
const good = TaskSchema.safeParse({ id: 1, title: "Buy milk", status: "todo" });
console.log(good.success);
const bad = TaskSchema.safeParse({ id: "one", title: "x", status: "todo" });
if (!bad.success) {
console.log(z.prettifyError(bad.error));
}src/schema.ts:
import { z } from "zod";
export const TaskSchema = z.discriminatedUnion("status", [
z.object({ id: z.number(), title: z.string(), status: z.literal("todo") }),
z.object({ id: z.number(), title: z.string(), status: z.literal("doing"), startedAt: z.string() }),
z.object({ id: z.number(), title: z.string(), status: z.literal("done"), finishedAt: z.string() }),
]);
export type Task = z.infer<typeof TaskSchema>;$ node src/check.ts
true
โ Invalid input: expected number, received string
โ at id5. Error
The result has two shapes, so we have to check success first. If we read data right away, tsc says: good dot data is possibly undefined. TS18048. On the failed shape there is no data, the same idea as the Result type from episode twenty two.
src/check.ts:
import { z } from "zod";
import { TaskSchema } from "./schema.ts";
const good = TaskSchema.safeParse({ id: 1, title: "Buy milk", status: "todo" });
console.log(good.success);
const bad = TaskSchema.safeParse({ id: "one", title: "x", status: "todo" });
if (!bad.success) {
console.log(z.prettifyError(bad.error));
}
console.log(good.data.title);$ npx tsc --noEmit
src/check.ts:10:13 - error TS18048: 'good.data' is possibly 'undefined'.
10 console.log(good.data.title);
~~~~~~~~~
Found 1 error in src/check.ts:106. Fix
Inside an if on success, data is there.
src/check.ts:
import { z } from "zod";
import { TaskSchema } from "./schema.ts";
const good = TaskSchema.safeParse({ id: 1, title: "Buy milk", status: "todo" });
console.log(good.success);
const bad = TaskSchema.safeParse({ id: "one", title: "x", status: "todo" });
if (!bad.success) {
console.log(z.prettifyError(bad.error));
}
if (good.success) {
console.log(good.data.title);
}$ node src/check.ts
true
โ Invalid input: expected number, received string
โ at id
Buy milk$ npx tsc --noEmit7. Replace the guard
Now our store. We remove the import of the old guard and the hand written check. Instead, z dot array of the task schema parses the whole list. If it fails, we throw the readable message. If it works, parsed dot data already has the right type.
src/store.ts:
import { z } from "zod";
import { TaskSchema } from "./schema.ts";
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import type { Task } from "./task.ts";
const FILE = "tasks.json";
export async function saveTasks(tasks: Task[]): Promise<void> {
await writeFile(FILE, JSON.stringify(tasks, null, 2));
}
export async function loadTasks(): Promise<Task[]> {
if (!existsSync(FILE)) {
return [];
}
const text = await readFile(FILE, "utf8");
const parsed = z.array(TaskSchema).safeParse(JSON.parse(text));
if (!parsed.success) {
throw new Error(z.prettifyError(parsed.error));
}
return parsed.data;
}$ npx tsc --noEmit8. Try it
We damage the file again, with a status that does not exist. Show stops with a clear message, and not just a vague one. Zod tells us what is wrong, and where.
$ echo '[{"id":1,"title":"x","status":"finished"}]' > tasks.json$ node src/show.ts
file:///home/mah/tasks/src/store.ts:20
throw new Error(z.prettifyError(parsed.error));
^
Error: โ Invalid discriminator value. Expected 'todo' | 'doing' | 'done'
โ at [0].status
at loadTasks (file:///home/mah/tasks/src/store.ts:20:11)
at async file:///home/mah/tasks/src/show.ts:4:20
Node.js v26.13.0Recap
Today: Zod schemas, the type from z infer, safe parse with a result that we must check, readable errors, and the store without the hand written guard. Next episode: the settings of a program, read from environment variables.