Intermediate to senior

Frontend Interview Prep

Fourteen chapters on HTML and CSS, core JavaScript, the event loop, browser rendering, React, state, performance, accessibility, security, TypeScript, testing, machine-coding components and frontend system design, with tested code.

Chapter 11 of 14Quality and security · TypeScript for Frontend Interviews

TypeScript for Frontend Interviews

TypeScript adds a static type system to JavaScript. Interviews test whether you can use types to prevent real bugs, not whether you can recite syntax: narrowing, generics, utility types, discriminated unions, and the difference between compile-time and run-time. The ts blocks in this chapter are type-checked with the TypeScript compiler in strict mode; blocks marked as errors show code that is meant to fail.

1. What types are for

  • Catch mistakes early: wrong property names, undefined access, wrong argument shapes.
  • Document intent: a function signature tells the reader what goes in and out.
  • Power tooling: autocomplete, safe refactors, find-all-references.

Types are erased at runtime. They do not validate data arriving from an API, a form or JSON.parse. For untrusted data you need run-time validation (Zod, Valibot, hand-written guards), then derive the type from the schema.

Turn on strict: true (it enables strictNullChecks, noImplicitAny and more). Without strict null checks, null and undefined silently inhabit every type, and most of the value of TypeScript disappears.

2. Basic types and inference

const count = 3;                          // inferred: number
let title = "Hello";                      // inferred: string
const tags: string[] = ["a", "b"];
const pair: [string, number] = ["age", 30];     // a tuple has fixed length and positions
const user: { id: number; name: string; email?: string } = { id: 1, name: "Asha" };

function add(a: number, b: number): number { return a + b; }
const double = (n: number) => n * 2;      // the return type is inferred

export { count, title, tags, pair, user, add, double };

Let TypeScript infer where it can (local variables, return types of small functions); annotate function parameters, exported APIs and anywhere inference would be too wide.

3. type versus interface

Both describe object shapes.

interfacetype
Object shapesyesyes
Extendingextends, and declaration mergingintersection &
Unions, tuples, primitives, mapped and conditional typesnoyes
Declaration merging (reopening)yesno

A practical rule: use interface for public object contracts that others may extend, type for unions and anything computed. Be consistent within a codebase.

4. Unions, narrowing and discriminated unions

A union says a value is one of several types. Before using it you must narrow it: TypeScript follows your checks.

function format(value: string | number | null): string {
  if (value === null) return "none";             // narrowed to string | number
  if (typeof value === "number") return value.toFixed(2);   // narrowed to number
  return value.toUpperCase();                    // narrowed to string
}

function describe(x: Date | string[]): string {
  return x instanceof Date ? x.toISOString() : x.join(",");  // instanceof narrows
}
export { format, describe };

Other narrowing tools: in ("name" in obj), truthiness checks, equality checks, Array.isArray, and user-defined type guards (function isUser(x: unknown): x is User).

Discriminated unions

Give each variant a literal tag field. Switching on it narrows precisely, and the compiler can prove you handled every case.

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function render(state: RequestState): string {
  switch (state.status) {
    case "idle": return "Start";
    case "loading": return "Loading...";
    case "success": return `${state.data.length} results`;     // data exists only here
    case "error": return `Failed: ${state.message}`;
    default: {
      const unreachable: never = state;          // compile error if a new variant is added and not handled
      return unreachable;
    }
  }
}
export { render };

This models UI state far better than { loading: boolean; data?: string[]; error?: string }, which allows impossible combinations (loading and error at the same time). Make illegal states unrepresentable.

5. unknown, any and never

  • any turns type checking off. It spreads silently. Avoid it.
  • unknown is the safe top type: you may hold any value but must narrow before using it. Use it for untrusted input.
  • never is the empty type: a function that never returns, or an impossible branch (used in exhaustiveness checks).
function parseId(input: unknown): number {
  if (typeof input === "number" && Number.isInteger(input)) return input;
  if (typeof input === "string" && /^\d+$/.test(input)) return Number(input);
  throw new Error("invalid id");
}

function isStringArray(x: unknown): x is string[] {
  return Array.isArray(x) && x.every(i => typeof i === "string");
}
export { parseId, isStringArray };

6. Generics

Generics let a function or type work over many types while keeping the relationship between input and output.

function first<T>(items: readonly T[]): T | undefined {
  return items[0];
}
const n = first([1, 2, 3]);          // number | undefined
const s = first(["a", "b"]);         // string | undefined

interface ApiResponse<T> { data: T; error: string | null }
async function getJson<T>(url: string): Promise<ApiResponse<T>> {
  const res = await fetch(url);
  return { data: (await res.json()) as T, error: null };   // the cast is a promise to the compiler, not a check
}

// constrain with extends
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(i => i[key]);
}
const names = pluck([{ id: 1, name: "a" }, { id: 2, name: "b" }], "name");   // string[]
export { n, s, getJson, names };

as T (a type assertion) tells the compiler to trust you; it does not verify anything at run time. Prefer validated parsing at boundaries.

7. Utility types

UtilityMeaning
Partial<T>all properties optional
Required<T>all required
Readonly<T>all read-only
Pick<T, K>only the listed keys
Omit<T, K>everything except the listed keys
Record<K, V>an object type with keys K and values V
ReturnType<F>the return type of a function type
Parameters<F>the parameter tuple of a function type
Awaited<T>the resolved type of a promise
NonNullable<T>removes null and undefined
Exclude<U, X> / Extract<U, X>filter a union
interface User { id: number; name: string; email: string; role: "admin" | "member" }

type UserPreview = Pick<User, "id" | "name">;
type NewUser = Omit<User, "id">;
type UserPatch = Partial<Omit<User, "id">>;
type RoleCount = Record<User["role"], number>;

const counts: RoleCount = { admin: 1, member: 20 };
const patch: UserPatch = { name: "Ravi" };
const preview: UserPreview = { id: 1, name: "Asha" };
const draft: NewUser = { name: "x", email: "[email protected]", role: "member" };

async function loadNames(): Promise<{ data: string[]; error: string | null }> {
  return { data: [], error: null };
}
type Loaded = Awaited<ReturnType<typeof loadNames>>;     // { data: string[]; error: string | null }
export { counts, patch, preview, draft };
export type { Loaded };

8. Mapped and conditional types (the idea)

A mapped type builds a type by transforming each key; a conditional type chooses a type with extends.

type Nullable<T> = { [K in keyof T]: T[K] | null };
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type EventName<T extends string> = `on${Capitalize<T>}`;

const maybe: Nullable<{ a: number; b: string }> = { a: null, b: "x" };
const elem: ElementOf<string[]> = "hello";
const ev: EventName<"click"> = "onClick";
export { maybe, elem, ev };

These are the machinery behind the utility types. In interviews you rarely write exotic ones, but you should read them and explain them.

9. TypeScript with React

// typing props, events and state (shown as plain TypeScript to keep the example runnable)
interface ButtonProps {
  label: string;
  onClick: (event: { shiftKey: boolean }) => void;
  variant?: "primary" | "secondary";
  disabled?: boolean;
}

function buttonClass({ variant = "primary", disabled = false }: Pick<ButtonProps, "variant" | "disabled">): string {
  return ["btn", `btn-${variant}`, disabled ? "is-disabled" : ""].filter(Boolean).join(" ");
}
const cls: string = buttonClass({ variant: "secondary" });
export { cls };

In JSX files the same ideas apply: props: ButtonProps, React.ChangeEvent<HTMLInputElement> for input events, useState<User | null>(null) when the initial value does not tell the whole type, useRef<HTMLInputElement>(null), and discriminated unions for component state. Prefer children: React.ReactNode. For polymorphic components and generics over props, keep types simple; complexity there is a smell.

10. Things that fail on purpose

These blocks are marked as errors and are not compiled by the checker; they show what TypeScript catches.

const user = { id: 1, name: "Asha" };
user.email;                         // error: Property 'email' does not exist on type '{ id: number; name: string; }'

function len(s: string | null) { return s.length; }   // error: 's' is possibly 'null'

type State = { status: "ok" } | { status: "fail"; reason: string };
function f(s: State) { return s.reason; }              // error: 'reason' does not exist on type '{ status: "ok" }'

11. Run-time versus compile-time

TypeScript's guarantees stop at the boundary of your code. An API can return anything; JSON.parse returns any; a cast can lie. Validate at the boundary and trust the types inside.

// the shape check that the type system cannot do for you, written as a plain run-time guard
function isUser(x) {
  return typeof x === "object" && x !== null && typeof x.id === "number" && typeof x.name === "string";
}
assert.ok(isUser(JSON.parse('{"id": 1, "name": "Asha"}')));
assert.ok(!isUser(JSON.parse('{"id": "1", "name": "Asha"}')));      // looks plausible but has the wrong type
assert.ok(!isUser(JSON.parse("null")));

12. Configuration worth knowing

  • strict (and specifically strictNullChecks, noImplicitAny), noUncheckedIndexedAccess (array and index lookups include undefined), exactOptionalPropertyTypes.
  • target, module, moduleResolution, jsx match your bundler.
  • isolatedModules is required by most transpilers (Babel, esbuild, SWC), which strip types without type-checking; run tsc --noEmit in CI for the actual checks.
  • satisfies checks that a value matches a type without widening it: const routes = { home: "/" } satisfies Record<string, string>.
  • Enums versus unions: prefer string-literal unions ("asc" | "desc") or as const objects; they erase cleanly and avoid enum quirks.

13. Common mistakes

  • Using any to silence errors; use unknown and narrow.
  • Casting with as instead of validating.
  • Leaving strict off.
  • Boolean flag soup instead of discriminated unions.
  • Over-engineered generic types that no one can read.
  • Assuming types validate API data.
  • Non-null assertions (!) that move a crash from compile time to run time.
  • Duplicating types that could be derived (typeof, keyof, ReturnType, schema inference).

14. Practice questions

  1. What does TypeScript add over JavaScript, and what can it not do?
  2. type versus interface: differences and when you choose each.
  3. What is narrowing? Show three ways to narrow a union.
  4. Model a request's loading, success and error states with a discriminated union and explain the benefit.
  5. unknown versus any versus never.
  6. Write a generic pluck<T, K extends keyof T> and explain the constraint.
  7. Explain Partial, Pick, Omit and Record; build Omit from other utilities.
  8. How do you type data from an API safely?
Header Logo