Type props, state and API responses so mistakes show up in your editor instead of in production.
Why it matters
Most front-end bugs are shape bugs: a prop that is sometimes undefined, an API field that was renamed, a status string with a typo. TypeScript catches these while you type. It is now the default on most professional front-end teams, and job descriptions for mid-level developers usually assume it.
TypeScript does not run in the browser. The compiler (tsc, or your bundler) checks the types and then strips them, leaving plain JavaScript. That has one big consequence you will meet below: types are not checked at runtime, so data from the network still has to be validated.
Typing props
Describe a component's props with a type (or interface) and annotate the parameter:
type ButtonProps = {
label: string;
variant?: "primary" | "secondary"; // optional, one of two strings
onClick: () => void;
disabled?: boolean;
};
export function Button({ label, variant = "primary", onClick, disabled = false }: ButtonProps) {
return (
<button type="button" className={`btn btn--${variant}`} onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
Now <Button label="Save" variant="danger" /> is an error twice: "danger" is not an allowed variant and onClick is missing. A union of string literals ("primary" | "secondary") is one of the most useful tools in UI code: it turns typos into compile errors and gives you autocomplete.
For components that wrap a native element, extend its props instead of re-listing them: type InputProps = React.ComponentProps<"input"> & { label: string }.
Typing state
useState infers the type from the initial value. Give it a type argument when the initial value does not tell the whole story:
const [count, setCount] = useState(0); // number, inferred
const [user, setUser] = useState<User | null>(null); // null until loaded
For anything with several related states, model them as a discriminated union instead of several booleans:
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: T };
With separate isLoading, error and data variables, nothing stops you from being "loading" and "error" at the same time. With the union, the status field decides which other fields exist, and TypeScript narrows the type when you check it:
if (state.status === "success") {
return <CourseList courses={state.data} />; // data exists here
}
if (state.status === "error") {
return <Alert>{state.message}</Alert>; // message exists here
}
Typing API responses
Write the response type once, next to the API call, and reuse it:
type User = { id: string; name: string; role: "admin" | "member" };
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json(); // typed as User... but nobody checked!
}
res.json() returns any, and the annotation is a promise you made, not a fact. If the server sends { "id": 7 }, TypeScript will not notice. At the boundary, validate the data at runtime and return a discriminated result:
type ParseResult = { ok: true; user: User } | { ok: false; error: string };
function parseUser(data: unknown): ParseResult {
if (typeof data !== "object" || data === null) return { ok: false, error: "not an object" };
const d = data as Record<string, unknown>;
if (typeof d.id !== "string" || d.id === "") return { ok: false, error: "id" };
// ...check the other fields...
return { ok: true, user: { id: d.id, name: d.name as string, role: d.role as User["role"] } };
}
Teams often use a schema library such as zod or valibot for this, which writes the check and the type from one definition. The idea is the same: unknown comes in, a checked type goes out.
Practical habits
- Turn on
"strict": trueintsconfig.json. Without it,nullandundefinedslip through silently. - Prefer
unknownoveranyfor data you have not checked.unknownforces you to narrow before use;anyswitches checking off. - Avoid
ascasts except right after a real runtime check. A cast is you telling the compiler to trust you. - Run
tsc --noEmit(oftennpm run typecheck) in CI so type errors block a merge. - Let inference work. You rarely need to annotate local variables or return values of small functions.
Common mistakes
- Typing the response and trusting it. Annotations do not validate. Check data from the network,
localStorageand URL params. - Optional everything. Marking every prop
?hides real bugs. Make a prop optional only when the component has a sensible default. - Boolean soup for async state. Replace
isLoading/isError/datawith one discriminated union. - Fighting the compiler with
any. If a type is hard to write, the shape is often too loose; simplify it.
Quick recap
- Type props with object types and string-literal unions for variants.
- Use discriminated unions for state with several modes; checking the tag narrows the type.
- Types disappear at runtime, so validate
unknownAPI data at the boundary. - Use
strictmode, preferunknowntoany, and run the type checker in CI.
Try it
The in-browser sandbox runs JavaScript, not TypeScript, so in the Do tab you will write the runtime half: a parseUser validator that returns the same { ok, user } / { ok, error } union you would declare in TypeScript.