TypeScript From Zero

TypeScript with React, Node and Runtime Validation

Typed React and Express, plus Zod validation and branded types, because types vanish at runtime.

Lesson 0630 min read#typescript#react#zod

On this page

  1. Words used in this lesson
  2. Typing React
  3. Typing Node and Express
  4. The key idea: types vanish, so validate at the edge
  5. Branded types
  6. Where to validate, and where not
  7. Try it
  8. Interview questions
  9. Key takeaways
  10. What's next

In one line: TypeScript makes your own code safe, but types are erased when the program runs, so any data from outside (a request body, a fetch response, process.env) is just a guess until you check it. Type the inside of the app with TypeScript, check the edge with a runtime schema (Zod), and get the TypeScript type from the same schema.

This lesson has three parts. First, typing React: props, children, events, useState, useReducer, useRef and a generic component. Second, typing a Node server with Express and a fetch call. Third, the key idea that ties them together: validate at the edge. For the React basics see Full-Stack Q&A, Lesson 4; for Node and Express see Full-Stack Q&A, Lesson 6. This lesson builds on the generic fetch wrapper from Lesson 3, where we wrote a parser by hand.

What I ran / checked. TypeScript 7.0.2, Node v22.22.0, @types/node 22, zod 4.6.5, react 19.3.0 with react-dom 19.3.0, @types/react 19.3.0, express 5.3.0 with @types/express 5.0.6, and tsx 4.23.15, all from npm. React code was type checked with tsc (jsx: react-jsx, moduleResolution: bundler) and rendered to a string with renderToString from react-dom/server, run through tsx; I did not run it in a browser, so no click or effect ran. The Express server and the fetch client ran in one script, on 127.0.0.1 with a random port. Output and error text are pasted as printed. I did not read the Zod source or docs for this lesson: what I say about Zod is what the installed version did when I ran it, and Zod's API changed between major versions, so check the version you use.

Words used in this lesson

  • Props are the inputs of a React component, passed like HTML attributes. Children is the content placed between the opening and closing tags.
  • Hook is a function such as useState that gives a component memory or lets it do something after rendering.
  • Event type is the type of the object a handler receives, such as ChangeEvent<HTMLInputElement>.
  • Reducer is a function (state, action) => newState. useReducer uses one.
  • Runtime validation is checking the real value while the program runs.
  • Schema is a description of the allowed shape of data, written as code. It can check a value and also give you the type.
  • Parse is checking a value against a schema and returning it with the right type, or failing.
  • Branded type is a normal type with an invisible tag, so that two things with the same shape (a user id and an order id, both strings) cannot be mixed up.
  • Edge (or boundary) is where data comes into your program from outside.

Typing React

React 19 and its @types/react package give you the types. The settings I used: "jsx": "react-jsx", "lib": ["es2023", "dom"] (the browser types), "moduleResolution": "bundler" and strict. That is the same idea as the Vite config in Lesson 5.

Props, children and defaults

JavaScript
import type { ReactNode, MouseEvent } from "react";type ButtonProps = {  label: string;  variant?: "primary" | "ghost";  onClick?: (e: MouseEvent<HTMLButtonElement>) => void;};function Button({ label, variant = "primary", onClick }: ButtonProps) {  return <button className={variant} onClick={onClick}>{label}</button>;}function Card({ title, children }: { title: string; children: ReactNode }) {  return <section><h2>{title}</h2>{children}</section>;}

Props are just an object type. A function component is a function that takes that object, and the return type is inferred, so I do not write it. ReactNode is the type of "anything React can show": text, numbers, elements, arrays, null. Use it for children. Optional props (variant?) have a default in the destructuring. Now the mistakes, as tsc reported them:

JavaScript
<Button /><Button label="x" variant="big" /><Button label="x" onClick={(e: string) => {}} /><Button label="x" colour="red" /><Card title="t" />
TEXT
error TS2741: Property 'label' is missing in type '{}' but required in type 'ButtonProps'.error TS2322: Type '"big"' is not assignable to type '"ghost" | "primary" | undefined'.error TS2322: Type '(e: string) => void' is not assignable to type '() => void'.  Target signature provides too few arguments. Expected 1 or more, but got 0.error TS2322: Type '{ label: string; colour: string; }' is not assignable to type 'IntrinsicAttributes & ButtonProps'.  Property 'colour' does not exist on type 'IntrinsicAttributes & ButtonProps'.error TS2741: Property 'children' is missing in type '{ title: string; }' but required in type '{ title: string; children: ReactNode; }'.

(In that file onClick was () => void, so the third error is about a function with a parameter. IntrinsicAttributes is where React puts key.) Union types for props are very useful: variant: "primary" | "ghost" is a dropdown in your editor and a compile error for "big".

To reuse the props of another component, ask for them: ComponentProps<typeof Button> is ButtonProps (I revealed it). This is the same Parameters idea as in Lesson 4.

Events

Each handler gets a typed event, and the element type is the argument:

JavaScript
const onChange = (e: ChangeEvent<HTMLInputElement>) => setName(e.target.value);const onSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); setAge(name.length); };

e.target.value is a string because the input element type says so. If you give the wrong element, tsc says it:

TEXT
error TS2551: Property 'valu' does not exist on type 'EventTarget & HTMLInputElement'. Did you mean 'value'?error TS2322: Type '(e: ChangeEvent<HTMLSelectElement>) => void' is not assignable to type 'ChangeEventHandler<HTMLInputElement, HTMLInputElement>'.  Types of parameters 'e' and 'event' are incompatible.    Type 'ChangeEvent<HTMLInputElement, HTMLInputElement>' is not assignable to type 'ChangeEvent<HTMLSelectElement, Element>'.      Type 'HTMLInputElement' is missing the following properties from type 'HTMLSelectElement': length, options, selectedIndex, selectedOptions, and 4 more.

(The first line is a typo, the second is an <input> given a handler for a <select>.) Read that second message from the bottom, as Lesson 0 taught: the last line says the real reason. The easiest way to type a handler is inline, onChange={(e) => ...}: then the type comes from the place it is used (contextual typing, Lesson 3) and you write nothing.

useState

useState infers the type from its first value. That works for useState("") and fails for empty starts:

JavaScript
const [list, setList] = useState([]);const [user, setUser] = useState(null);const [typed, setTyped] = useState<string[]>([]);setList(["a"]);setUser({ name: "Ada" });setTyped([1]);
TEXT
error TS2322: Type 'string' is not assignable to type 'never'.error TS2353: Object literal may only specify known properties, and 'name' does not exist in type '(prevState: null) => null'.error TS2322: Type 'number' is not assignable to type 'string'.

useState([]) is never[] (nothing can go in) and useState(null) is null (nothing but null can). When the first value does not show the full type, give the type argument: useState<string[]>([]) and useState<User | null>(null). The third line shows the benefit: a wrong item is refused.

useRef

JavaScript
const ref = useRef<HTMLInputElement>(null);const ref2 = useRef(0);ref.current.focus();ref2.current = "x";
TEXT
error TS18047: 'ref.current' is possibly 'null'.error TS2322: Type 'string' is not assignable to type 'number'.

A ref to a DOM element starts as null (before React fills it in), so you must check: ref.current?.focus(). A ref that holds a value (a timer id, a count) takes its type from the starting value.

useReducer and a discriminated union

A reducer is the best fit for TypeScript, because the action is a discriminated union (Lesson 1) and switch narrows it:

JavaScript
type State = { count: number; step: number };type Action = { type: "inc" } | { type: "setStep"; step: number } | { type: "reset" };function reducer(state: State, action: Action): State {  switch (action.type) {    case "inc": return { ...state, count: state.count + state.step };    case "setStep": return { ...state, step: action.step };    case "reset": return { count: 0, step: 1 };  }}const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });

Inside case "setStep", action.step exists because the compiler knows which member it is. dispatch is typed to take an Action. If I add a new action and forget its case, the return type check catches it. In another test with a missing case, tsc printed:

TEXT
error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

A generic component

Lesson 3's generics work for components. A list that works for any item type and keeps it:

JavaScript
type ListProps<T> = {  items: T[];  getKey: (item: T) => string | number;  render: (item: T) => ReactNode;};function List<T>({ items, getKey, render }: ListProps<T>) {  return <ul>{items.map((it) => <li key={getKey(it)}>{render(it)}</li>)}</ul>;}<List items={[{ id: 1, name: "Ada" }, { id: 2, name: "Bo" }]}      getKey={(u) => u.id} render={(u) => u.name.toUpperCase()} />

T is inferred from items, so u in both callbacks is { id: number; name: string } with no note from me. I rendered the whole tree with renderToString and got:

TEXT
<section><h2>Demo</h2><button class="primary">count 0</button><form><input value=""/><p>no age</p></form><ul><li>ADA</li><li>BO</li></ul></section>

This shows that the types do not change the HTML at all: they are erased, and React sees plain objects.

Typing Node and Express

@types/express gives Request and Response types with type parameters: Request<Params, ResBody, ReqBody, Query> and Response<ResBody>. The important thing first: by default the body is any.

JavaScript
app.post("/loose", (req, res) => {  const age: number = req.body.age; // compiles  res.json({ age });});

tsc printed nothing. I sent { "age": "x" } and the server answered:

TEXT
200 { age: 'x' }

The variable age is typed number, and holds "x". This is the problem from Lesson 0 (JSON.parse(...) as ...) in a new coat. Use the type parameters to say what you promise, and unknown for the body, then check it:

JavaScript
type UserOut = { id: number; name: string; age: number };app.get("/users/:id", (req: Request<{ id: string }>, res: Response<UserOut | { error: string }>) => {  const id = Number(req.params.id);  const user = users.get(id);  if (!user) { res.status(404).json({ error: "not found" }); return; }  res.json(user);});

Here the route parameters are a string (they always are in a URL) and the response body is a union. tsc then protects both sides. A different file with deliberate mistakes:

TEXT
error TS2322: Type 'string' is not assignable to type 'number'.                 (const id: number = req.params.id)error TS2339: Property 'idd' does not exist on type '{ id: string; }'.error TS2345: Argument of type '{ id: number; }' is not assignable to parameter of type 'UserOut | { error: string; } | undefined'.   (res.json({ id: 1 }))error TS2322: Type 'number' is not assignable to type 'string'.                 (res.status(404).json({ error: 404 }))

So the response is checked against your type. What is not checked is the request: nothing in Express can look at the bytes a client sends. That is the next section.

The key idea: types vanish, so validate at the edge

Everything you declare as a type is deleted before the program runs (Lesson 0). So data from outside needs a check that exists at runtime. The places: request bodies, query strings, fetch responses, JSON.parse, localStorage, files, message queues, and process.env.

The pattern is the same everywhere: take unknown, check it, and get a typed value or a clear error. You can write that by hand with typeof checks (Lesson 3's parseTodo), but it is long and it drifts away from the type. A schema library gives you both from one definition. Zod is the common one. I used version 4.6.5.

JavaScript
import { z } from "zod";const UserSchema = z.object({  id: z.number().int(),  name: z.string().min(1),  email: z.email(),  role: z.enum(["admin", "user"]).default("user"),  tags: z.array(z.string()).optional(),});type User = z.infer<typeof UserSchema>;

z.infer<typeof UserSchema> is the TypeScript type of a valid value. I revealed it with the never trick:

TEXT
{ id: number; name: string; email: string; role: "admin" | "user"; tags?: string[] | undefined; }

There is one source of truth: the schema. Change it, and the type changes with it. Note that role is a plain (not optional) "admin" | "user" in the output type, because the .default("user") fills it.

parse and safeParse

JavaScript
const good: unknown = JSON.parse('{"id": 1, "name": "Ada", "email": "ada@example.com"}');const bad: unknown = JSON.parse('{"id": "1", "name": "", "email": "nope"}');const u: User = UserSchema.parse(good);console.log(u);const r = UserSchema.safeParse(bad);if (!r.success) {  console.log(r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`));} else {  console.log(r.data.name);}
TEXT
{ id: 1, name: 'Ada', email: 'ada@example.com', role: 'user' }[  'id: Invalid input: expected number, received string',  'name: Too small: expected string to have >=1 characters',  'email: Invalid email address']
parse(x)safeParse(x)
Successreturns the typed valuereturns { success: true, data }
Failurethrows a ZodError (I checked e instanceof z.ZodError)returns { success: false, error }, never throws
Use whenbad data is a bug or should reach a global error handlerbad data is normal, such as user input: you reply with a 400

safeParse returns a discriminated union (Lesson 1), so after if (!r.success) return, TypeScript knows r.data exists. Two details I observed: the output has role: 'user' that the input did not have (defaults run), and unknown keys are removed: z.object({ a: z.string() }).parse({ a: "1", extra: 2 }) returned { a: '1' }. If you want unknown keys to be an error, use z.strictObject; mine reported Unrecognized key: "extra".

Do not parse and then ignore the result. The point is to use the returned value, which is typed. If you check with schema.parse(body) and keep using the original body, you still hold the unchecked value.

Narrowing unknown by hand, and the lying type guard

You can narrow without a library: an unknown value must be narrowed with typeof, in, instanceof and so on before use (Lesson 1). A function can tell the compiler the result with a type guard, x is Todo. But the guard is only as good as its body, and the compiler trusts it blindly:

JavaScript
function isTodo(x: unknown): x is Todo {  return typeof x === "object" && x !== null && "id" in x && typeof x.id === "number";}

I fed it { id: 1, title: "write", done: "no" } (a wrong done):

TEXT
weak guard says: true but done is string

The guard checked only id, said yes, and the type said done is a boolean while it was the text "no". A schema checks every field, so it cannot be this lazy. Prefer a schema; use a hand-written guard only for tiny checks you can see at a glance.

A typed fetch with a schema

Lesson 3 ended with a fetchJson(url, parse). With Zod the parser is the schema, and the result type comes from it:

JavaScript
async function fetchJson<S extends z.ZodType>(url: string, schema: S): Promise<z.infer<S>> {  const res = await fetch(url);  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);  const raw: unknown = await res.json();  return schema.parse(raw);}const Todo = z.object({ id: z.number(), title: z.string(), done: z.boolean() });const t = await fetchJson(`${base}/todo/1`, Todo);console.log(t.title.toUpperCase(), t.done);

S extends z.ZodType is a constraint (Lesson 3) and z.infer<S> reads the output type of whatever schema was passed. I ran it against a local server with a good and a bad /todo/bad response (done was "no"):

TEXT
WRITE false✖ Invalid input: expected boolean, received string  → at done

z.prettifyError(error) made that readable message. The first call is fully typed (t.title is a string), and the bad data stops at the edge with the field name, not later in unrelated code.

Validating a request in Express

Back to the server. The body is unknown, and safeParse decides:

JavaScript
const CreateUser = z.object({ name: z.string().min(1), age: z.number().int().min(0) });app.post("/users", (req: Request<{}, UserOut | { error: unknown }, unknown>, res) => {  const parsed = CreateUser.safeParse(req.body);  if (!parsed.success) {    res.status(400).json({ error: z.flattenError(parsed.error).fieldErrors });    return;  }  const user: UserOut = { id: nextId++, ...parsed.data };  users.set(user.id, user);  res.status(201).json(user);});

I started the server and posted a good and a bad body, and then read a user back:

TEXT
201 { id: 1, name: 'Ada', age: 36 }400 {  error: {    name: [ 'Too small: expected string to have >=1 characters' ],    age: [ 'Invalid input: expected number, received string' ]  }}200 { id: 1, name: 'Ada', age: 36 }404 { error: 'not found' }

After the if, parsed.data is { name: string; age: number } for real. The same schema could also be shared with the frontend, so that the form and the server agree. Writing if (!parsed.success) in every route is repetitive; many projects put it in a small middleware that takes a schema.

Environment variables

process.env.X is string | undefined (Lesson 5 showed that augmenting ProcessEnv only hides this). The fix is to parse the environment once at startup:

JavaScript
const EnvSchema = z.object({  DATABASE_URL: z.url(),  PORT: z.coerce.number().int().min(1).max(65535).default(3000),  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),  DEBUG: z.stringbool().default(false),});export type Env = z.infer<typeof EnvSchema>;const env = EnvSchema.parse(process.env);

Environment variables are always text. z.coerce.number() turns "8080" into the number 8080, and z.stringbool() turns "true" into true. I ran safeParse on two inputs:

TEXT
{  DATABASE_URL: 'postgres://localhost/app',  PORT: 8080,  NODE_ENV: 'development',  DEBUG: true}✖ Invalid input: expected string, received undefined  → at DATABASE_URL✖ Too big: expected number to be <=65535  → at PORT✖ Invalid option: expected one of "development"|"production"|"test"  → at NODE_ENV

The second input was { PORT: "99999", NODE_ENV: "staging" }: three problems reported at once, and the app refuses to start with all of them listed. That is much better than a crash an hour later. Also, input and output types differ here: for z.object({ n: z.coerce.number(), d: z.string().default("x") }) I revealed z.input as { n: unknown; d?: string | undefined; } and z.output (the same as z.infer) as { n: number; d: string; }.

Why a project-wide env object? Parse once in one file, export the typed result, and import it everywhere. No other file reads process.env. Then a missing variable is an error at startup, and the rest of the code sees number and boolean, never string | undefined.

Branded types

Two different things can have the same shape: a user id and an order id are both string. TypeScript treats string as string, so you can pass one where the other is expected. A brand adds an invisible tag. The hand-made version:

JavaScript
type Brand<T, B extends string> = T & { readonly __brand: B };type Email = Brand<string, "Email">;type Cents = Brand<number, "Cents">;function toEmail(s: string): Email | null {  return /^[^@\s]+@[^@\s]+$/.test(s) ? (s as Email) : null;}function send(to: Email, body: string) { return `to ${to}: ${body}`; }const cents = (n: number): Cents => Math.round(n) as Cents;function charge(c: Cents) { return c + 1; }send("ada@example.com", "hi");charge(5);
TEXT
error TS2345: Argument of type 'string' is not assignable to parameter of type 'Email'.error TS2345: Argument of type 'number' is not assignable to parameter of type 'Cents'.

The only way to get an Email is through toEmail, which checks it. The __brand property does not exist at runtime: a branded value is the plain string (I ran send(e, "hi") and it printed to ada@example.com: hi). Honest limits, which I tested: a branded Cents still adds like a number (const sum: number = cents(1) + cents(2) compiled), and "nope" as Email is accepted, because as can always create a fake. The brand is a rule for honest code, not a lock.

Zod has a built-in version, .brand<"Name">(), and so the schema is the one place that creates the branded value:

JavaScript
const UserId = z.uuid().brand<"UserId">();type UserId = z.infer<typeof UserId>;const OrderId = z.string().brand<"OrderId">();function loadUser(id: UserId) { return id; }loadUser(UserId.parse("550e8400-e29b-41d4-a716-446655440000"));loadUser("550e8400-e29b-41d4-a716-446655440000");loadUser(OrderId.parse("o1"));
TEXT
error TS2345: Argument of type 'string' is not assignable to parameter of type 'string & $brand<"UserId">'.error TS2345: Argument of type 'string & $brand<"OrderId">' is not assignable to parameter of type 'string & $brand<"UserId">'.  ...      Property 'UserId' is missing in type '{ OrderId: true; }' but required in type '{ UserId: true; }'.

The first call is fine. A raw string and an OrderId are both refused. The message at the bottom is the useful part: UserId is missing.

Where to validate, and where not

Validate request bodies, query strings, third-party fetch responses, files and process.env. Do not validate data passed between your own typed functions: the compiler already checked it, and parsing the same value again is noise. A schema also does not replace authorization: it checks shape and rules, not whether the user may do the action.

Try it

  1. Write a Button component with a prop size: "sm" | "lg". Call it with size="md". What does tsc say?
  2. In a component, write useState([]) and then setList(["a"]). Fix it with a type argument.
  3. Write a reducer with three actions, then delete one case. What does tsc report?
  4. Write a Schema for { name: string, age: number } with Zod and use z.infer. Pass a body with age: "x" to safeParse and print the issues.
  5. Write an EnvSchema with a PORT that has a default of 3000. What is the type of env.PORT, and what does the program do with PORT=abc?
  6. Make type UserId = Brand<string, "UserId"> and type OrderId = Brand<string, "OrderId">. Pass one to a function that wants the other.

Answers.

  1. error TS2322: Type '"md"' is not assignable to type '"sm" | "lg"'. (Same shape as the "big" error above, which I ran.)
  2. Type 'string' is not assignable to type 'never'. Use useState<string[]>([]).
  3. error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
  4. The issue list has age: Invalid input: expected number, received string.
  5. number, because the default and the coerce are applied. With PORT=abc, z.coerce.number() cannot make a number, and safeParse fails. (I ran 99999, which failed on the maximum; I did not run abc.)
  6. Type 'string & $brand<"OrderId">' is not assignable ... with Zod, or Type 'Brand<string, "OrderId">' is not assignable to type 'Brand<string, "UserId">' for the hand-made version (the second message I did not capture word for word).

Interview questions

Why can TypeScript not protect me from bad data from an API? (Must know)

Because types are erased before the program runs, and the checker only compares my code with the types I wrote. It cannot look at the bytes that arrive. In my Express test, const age: number = req.body.age compiled, and when a client sent "x", the server answered { age: 'x' }. I need a runtime check at the edge of the program.

What does z.infer do, and why is it useful? (Must know)

It gives the TypeScript type of the value a Zod schema accepts, so the schema and the type cannot disagree. I write const UserSchema = z.object({...}); type User = z.infer<typeof UserSchema>; and have one definition. If a field changes, the check and the type change together. z.input is the type before defaults and coercion; z.output (the same as z.infer) is the type after.

parse or safeParse? (Must know)

parse throws a ZodError on bad data and returns the typed value on good data. safeParse never throws and returns a union, { success: true, data } or { success: false, error }. I use safeParse when bad input is normal, such as a request body, so I can reply 400. I use parse when bad data is a bug or when something will catch the error anyway, such as at startup.

How do you validate environment variables? (Must know)

I define a schema once, parse process.env at startup, export the typed result and never read process.env anywhere else. Variables are always strings, so I use z.coerce.number() for ports and z.stringbool() for flags. In my run, one bad input reported three problems at once (DATABASE_URL missing, PORT too big, NODE_ENV not allowed), so the app can refuse to start with a full list.

How do you type useState when the starting value is empty? (Must know)

I give the type argument: useState<User | null>(null) and useState<string[]>([]). Without it, useState([]) is never[] and useState(null) is just null. In my test, setList(["a"]) failed with Type 'string' is not assignable to type 'never'.

How do you type event handlers in React? (Must know)

With ChangeEvent<HTMLInputElement>, FormEvent<HTMLFormElement> and MouseEvent<HTMLButtonElement>, where the argument is the element type. Usually I write the handler inline (onChange={(e) => ...}) and the type comes from the place for free. When a handler is typed for the wrong element, tsc says ChangeEventHandler<HTMLInputElement> is not assignable from a <select> handler, and the last indented line names the missing properties.

Why is useRef<HTMLInputElement>(null) possibly null? (Must know)

The ref is null until React attaches the element after the first render. tsc reports TS18047: 'ref.current' is possibly 'null'. I use ref.current?.focus() or check first, inside an effect or an event handler. A ref that holds a plain value (useRef(0)) takes its type from the start value.

Why is useReducer a good fit for TypeScript? (Senior signal)

The action is a discriminated union with a type field, so each case narrows action and action.step only exists in the right case. dispatch only accepts valid actions. If I add an action and forget the case, a reducer with an explicit return type fails with TS2366, or I can add a never check in a default. Bad shapes cannot be dispatched.

How do you type an Express handler? (Senior signal)

Request takes four type parameters in this order: route params, response body, request body, query. Response takes the body type. I type the route params (Request<{ id: string }>) and the response (Response<UserOut | { error: string }>) so res.json({ error: 404 }) fails. For the request body I use unknown and safeParse it, because by default req.body is any.

What is a branded type and why use one? (Senior signal)

It is a type with an invisible tag, T & { __brand: "Name" }. It stops me from mixing two values that have the same shape, such as a UserId and an OrderId, both string. The tag exists only in the type; at runtime it is the plain value. The limits: as can fake a brand, and the arithmetic of a branded number still works. I create brands in one place, in a function or a Zod schema that validates the value.

What is wrong with a hand-written type guard like x is Todo? (Trap)

The compiler trusts the body of the guard completely. My test guard checked only id, and returned true for an object whose done was the text "no", so the code afterwards believed done was a boolean. A guard that does not check every field is a cast in disguise. I use a schema when the shape matters, and a guard only for tiny, obvious checks.

Key takeaways

  • React: type props as an object type, children as ReactNode, events as ChangeEvent<HTMLInputElement> (or write handlers inline), and give useState a type argument when the start value is empty. Refs to elements start as null.
  • A reducer with a discriminated-union action is the best fit for TypeScript, and a generic component keeps the item type.
  • In Express, req.body is any by default. Type the params and the response, and treat the body as unknown.
  • Types are erased, so data from outside needs a runtime check. Use a schema (Zod 4.6.5 in my tests), get the type from z.infer, and prefer safeParse for normal bad input.
  • Parse process.env once at startup; use coerce and stringbool because env values are text.
  • A hand-written type guard that checks too little is a lie. A schema checks every field.
  • Brand values with the same shape, and create each brand in one validated place. A brand can still be faked with as.

What's next

Lesson 7: Common Errors, Strictness Migration and Reading Error Messages. We collect the ten errors you will meet most, with real messages and fixes, read long errors from the bottom up, and move a loose project to strict one flag at a time.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series