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.
On this page
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
useStatethat 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.useReduceruses 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
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:
<Button /><Button label="x" variant="big" /><Button label="x" onClick={(e: string) => {}} /><Button label="x" colour="red" /><Card title="t" />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:
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:
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:
const [list, setList] = useState([]);const [user, setUser] = useState(null);const [typed, setTyped] = useState<string[]>([]);setList(["a"]);setUser({ name: "Ada" });setTyped([1]);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
const ref = useRef<HTMLInputElement>(null);const ref2 = useRef(0);ref.current.focus();ref2.current = "x";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:
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:
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:
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:
<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.
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:
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:
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:
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.
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:
{ 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
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);}{ 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) | |
|---|---|---|
| Success | returns the typed value | returns { success: true, data } |
| Failure | throws a ZodError (I checked e instanceof z.ZodError) | returns { success: false, error }, never throws |
| Use when | bad data is a bug or should reach a global error handler | bad 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:
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):
weak guard says: true but done is stringThe 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:
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"):
WRITE false✖ Invalid input: expected boolean, received string → at donez.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:
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:
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:
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:
{ 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_ENVThe 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:
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);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:
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"));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
- Write a
Buttoncomponent with a propsize: "sm" | "lg". Call it withsize="md". What doestscsay? - In a component, write
useState([])and thensetList(["a"]). Fix it with a type argument. - Write a
reducerwith three actions, then delete onecase. What doestscreport? - Write a
Schemafor{ name: string, age: number }with Zod and usez.infer. Pass a body withage: "x"tosafeParseand print the issues. - Write an
EnvSchemawith aPORTthat has a default of3000. What is the type ofenv.PORT, and what does the program do withPORT=abc? - Make
type UserId = Brand<string, "UserId">andtype OrderId = Brand<string, "OrderId">. Pass one to a function that wants the other.
Answers.
error TS2322: Type '"md"' is not assignable to type '"sm" | "lg"'.(Same shape as the"big"error above, which I ran.)Type 'string' is not assignable to type 'never'. UseuseState<string[]>([]).error TS2366: Function lacks ending return statement and return type does not include 'undefined'.- The issue list has
age: Invalid input: expected number, received string. number, because the default and the coerce are applied. WithPORT=abc,z.coerce.number()cannot make a number, andsafeParsefails. (I ran99999, which failed on the maximum; I did not runabc.)Type 'string & $brand<"OrderId">' is not assignable ...with Zod, orType '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,
childrenasReactNode, events asChangeEvent<HTMLInputElement>(or write handlers inline), and giveuseStatea type argument when the start value is empty. Refs to elements start asnull. - A reducer with a discriminated-union action is the best fit for TypeScript, and a generic component keeps the item type.
- In Express,
req.bodyisanyby default. Type the params and the response, and treat the body asunknown. - 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 prefersafeParsefor normal bad input. - Parse
process.envonce at startup; usecoerceandstringboolbecause 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
- Validation at the Edge: Schemas, DTOs and Trusting Nothing · Backend in Depth
- “The API Is Fast but the UI Is Slow”: How to Solve It · Scenario Debugging
- Frontend and React Scenario Bank · Scenario Debugging
Part of the study guide
Related series