The error you already know

In pydantic you never trusted the wire. You ran the bytes through a model and let model_validate decide whether they deserved to be called a User. That habit is the most transferable thing you carried over, and it still holds here. The wire still lies, and the honest type for whatever JSON.parse returns is unknown.

user.ts
type User = { name: string; age: number };
// You could annotate JSON.parse as User
// and the compiler would nod.
// pydantic trained you out of that, so
// you keep the honest type instead.
const data: unknown = JSON.parse(payload);
// The honest type refuses to be read until
// something proves what it holds.
// This is the boundary doing its job,
// before a single field is touched.
const user: User = {
name: data.name,
age: data.age,
};

You could have annotated the result User and moved on. TypeScript would have believed you, and the cost would have come due later, at runtime, in a field that was a string when you promised a number. The honest unknown moves that cost forward to right here, where you can still do something about it. The compiler will not let you read data.name until something proves the shape. That something is the part pydantic used to do for you.

user.ts(10,28) 'data' is of type 'unknown'.

Watch the habit become the idiom

The fix is pydantic's idea, spelled for a language where the types are gone by the time the program runs. A pydantic model is two things wearing one name. It is a runtime validator and it is a type. zod splits that one job into a schema you can run and a type you can read off it, so you still describe the shape only once.

Step through it at your own pace. Each click is one move.

Step 1 of 4

pydantic, the habit you trust. BaseModel names the fields, model_validate checks the JSON before you touch it.

user.pyPython
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int
    nickname: str | None = None

# Validate the untrusted JSON
# at the boundary.
user = User.model_validate(payload)

Here's what each move does.

  • A BaseModel becomes a z.object(). The fields line up one for one, name: str turning into name: z.string().
  • An Optional[str] becomes .optional(), which means the key is allowed to be missing. That is a different thing from | null, which is a value that is present and empty. Keep the two apart.
  • A validator becomes .refine(). Where you wrote a field_validator that raised on a bad value, you chain .refine((n) => n >= 0) onto the field and keep the rule next to the shape.
  • model_validate becomes .parse(). It runs the schema against the data and throws when the shape is wrong, exactly where you want the failure to land.

The one move with no pydantic counterpart is the last line of the morph. z.infer<typeof User> reads the schema and produces the static type, so the value you check at runtime and the type the compiler checks come from one definition. In pydantic the class already was both. In TypeScript you write the schema and let z.infer hand you the type, and the shape is never written twice.

zod is not in this project, and you will not import it. The point of the lesson is the function living underneath .parse(). Write that function by hand once and you will know exactly what zod is doing for you the day you do reach for it.

Now make the compiler agree

Here is that boundary as plain TypeScript, no library in sight. parseUser takes unknown, the honest type for parsed JSON, and right now it lies. It asserts the value is a User without checking a thing. Replace the assertion with real runtime checks. Prove input is an object, prove name is a string and age is a number, throw when either fails, and return the value the compiler can finally trust. Keep the parameter unknown. This is the work zod automates for you, one .parse() standing in for the whole guard.

Loading...
type-checking…