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

TypeScript for UI

Reading35mLesson 2 of 3 in this module

What you'll learn

  • Type props, state and API responses
  • Use string-literal unions for variants and discriminated unions for async state
  • Validate unknown network data at runtime because types disappear after compilation
  • Apply strict mode, prefer unknown to any, and run the type checker in CI

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:

TSX
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:

TSX
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:

TypeScript
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:

TSX
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:

TypeScript
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:

TypeScript
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.

Key terms (5)

union of string literals
A type that allows only listed strings, such as "primary" | "secondary", turning typos into compile errors.
discriminated union
A union of object types that share a tag field, such as status, whose value decides which other fields exist.
narrows
TypeScript narrows a type when a check, such as comparing the status tag, proves which member of a union a value is.
unknown
A type for values not yet checked; unlike any, it forces you to narrow before using the value.
strict
The tsconfig.json setting that turns on TypeScript's strictest checks, including null and undefined handling.

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, avoid unchecked as casts, and run the type checker in CI.
Also in this lesson: