M Logo
๐Ÿ“ TypeScript from Zero ยท Part 34 of 50

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.

Setup

We start in the terminal and open the project in VS Code:

bash
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.

text
$ 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 vulnerabilities

2. 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:

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:

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>;
text
$ node src/check.ts
true
โœ– Invalid input: expected number, received string
  โ†’ at id

5. 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:

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);
text
$ 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:10

6. Fix

Inside an if on success, data is there.

src/check.ts:

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);
}
text
$ node src/check.ts
true
โœ– Invalid input: expected number, received string
  โ†’ at id
Buy milk
bash
$ npx tsc --noEmit

7. 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:

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;
}
bash
$ npx tsc --noEmit

8. 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.

bash
$ echo '[{"id":1,"title":"x","status":"finished"}]' > tasks.json
text
$ 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.0

Recap

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.

ยฉ 2026 All Rights Reserved By Mahmudul Alam