Skip to main content
Back to module
Front-End Engineering · Front-End Architecture

TypeScript for UI

Reading35mLesson 2 of 3 in this module

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": true in tsconfig.json. Without it, null and undefined slip through silently.
  • Prefer unknown over any for data you have not checked. unknown forces you to narrow before use; any switches checking off.
  • Avoid as casts except right after a real runtime check. A cast is you telling the compiler to trust you.
  • Run tsc --noEmit (often npm 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, localStorage and 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/data with 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 unknown API data at the boundary.
  • Use strict mode, prefer unknown to any, 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.

Also in this lesson: