TypeScriptGenericsUtility TypesReactFAANG Prep

TypeScript Interview — The Complete Guide

Everything you need for TypeScript rounds at product companies. Core type system, generics, advanced patterns, React typing, and the questions that actually get asked.

90 min read8 sections
01

Core Type System

TypeScript's type system is structural, not nominal. Two types are compatible if their shapes match — regardless of name. This is the single most important concept to internalize before any TS interview. Coming from Java or C#, this feels wrong at first, but it's what makes TypeScript so flexible with JavaScript's duck-typed nature.

🔥 type vs interface

Both define object shapes, but they have different strengths. interface supports declaration merging — if you declare the same interface twice, TypeScript combines them. This is how libraries like Express let you extend Request. type is more powerful for computed types — unions, intersections, mapped types, and conditional types all require type.

types.tstypescript
// interface — extendable, mergeable
interface User {
  id: string;
  name: string;
}
interface User {
  email: string; // declaration merging
}

// type — unions, intersections, computed
type Status = "active" | "inactive" | "banned";
type UserWithStatus = User & { status: Status };

Interview rule of thumb

Use interface for extendable object shapes. Use type for unions, intersections, and anything computed.

🔥 Discriminated Unions

Discriminated unions are the most interview-relevant pattern in TypeScript. The idea: every member of a union has a common literal property (the "discriminant") that TypeScript uses to narrow the type. When you switch on that property, TS knows exactly which branch you're in and gives you the right properties. This is how you model state machines, API responses, and component variants.

discriminated.tstypescript
// Discriminated union
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "rect":   return s.width * s.height;
  }
}

🔥 unknown vs any vs never

TypeWhat it meansWhen to use
anyOpts out of type checkingMigration from JS (avoid in prod)
unknownMust narrow before useAPI responses, JSON.parse results
neverImpossible valueExhaustive checks, throw-only functions
unknown.tstypescript
// unknown forces you to narrow
function parse(input: unknown): string {
  if (typeof input === "string") return input;
  if (typeof input === "number") return String(input);
  throw new Error("Unsupported type");
}

// never for exhaustive switch
function assertNever(x: never): never {
  throw new Error("Unexpected value: " + x);
}

🔥 Type Guards

TypeScript narrows types automatically with typeof, instanceof, in, and truthiness checks. But for custom objects, you need custom type guards using the is keyword. A type guard is a function that returns a boolean, but its return type is annotated as param is Type — this tells TypeScript to narrow the type in the calling scope after the check passes.

guards.tstypescript
// Custom type guard
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === "object" &&
    obj !== null &&
    "id" in obj &&
    "name" in obj
  );
}

if (isUser(data)) {
  console.log(data.name); // TS knows data is User
}
02

Generics

Generics let you write reusable, type-safe code without losing type information. Without generics, you'd either use any (losing safety) or write duplicate functions for each type. Generics are the backbone of every utility type, most library APIs, and any function that works with "some type T" without caring what T is.

🔥 Generic Functions & Constraints

A generic function declares a type parameter <T> that gets inferred from usage. Constraints (extends) restrict what T can be — this is how you say "T must have a length property" without specifying the exact type. The constraint ensures you can safely access .length inside the function body.

generics.tstypescript
function identity<T>(value: T): T {
  return value;
}

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest("hello", "hi");       // works
longest([1, 2, 3], [1]);      // works

🔥 Defaults & Multiple Params

Generic defaults (<T = unknown>) let callers omit the type parameter when a sensible default exists — like ApiResponse defaulting to unknown data. Multiple type parameters let you express relationships between inputs and outputs, like a merge function that returns the intersection of both input types.

defaults.tstypescript
type ApiResponse<T = unknown> = {
  data: T;
  status: number;
  error?: string;
};

function merge<A extends object, B extends object>(a: A, b: B): A & B {
  return { ...a, ...b };
}

🔥 Conditional Types & infer

Conditional types are TypeScript's if/else at the type level: T extends U ? X : Y. They distribute over unions automatically — (A | B) extends U ? X : Y becomes (A extends U ? X : Y) | (B extends U ? X : Y). The infer keyword lets you extract a type from within another type — it declares a type variable that TS fills in by pattern matching. This is how ReturnType, Parameters, and Awaited work internally.

conditional.tstypescript
type IsString<T> = T extends string ? true : false;

// infer — extract return type
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
type X = ReturnOf<() => string>;  // string

// infer — extract array element
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Z = ElementOf<string[]>; // string

Interview favorite

"Implement ReturnType from scratch" is one of the most common TypeScript interview questions. Know the infer pattern cold.

03

Utility Types

TypeScript ships with built-in utility types that transform existing types. Interviewers expect you to know what they do AND how they're implemented — because the implementations reveal how mapped types, conditional types, and keyof work together. If you can implement Pick from scratch, you understand 80% of TypeScript's type-level programming.

UtilityWhat it doesImplementation
Partial<T>All props optional{ [K in keyof T]?: T[K] }
Required<T>All props required{ [K in keyof T]-?: T[K] }
Readonly<T>All props readonly{ readonly [K in keyof T]: T[K] }
Pick<T, K>Pick specific keys{ [P in K]: T[P] }
Omit<T, K>Remove specific keysPick<T, Exclude<keyof T, K>>
Record<K, V>Object with keys K, values V{ [P in K]: V }

🔥 Implement Pick from Scratch

This is the most common "implement a utility type" interview question. Pick uses a mapped type to iterate over a subset of keys K (constrained to keyof T) and maps each to its corresponding value type. Understanding this one pattern unlocks Omit, Partial, Required, and Readonly — they all use the same [K in keyof T] iteration.

pick.tstypescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

interface User {
  id: string; name: string; email: string; age: number;
}
type UserPreview = MyPick<User, "id" | "name">;

🔥 Custom Utility Types

Real-world codebases need utilities that TypeScript doesn't ship. DeepPartial recursively makes every nested property optional — essential for patch/update APIs. DeepReadonly prevents mutation at every level, not just the top. These use recursive conditional types: if the property value is an object, recurse; otherwise, apply the modifier directly.

custom-utils.tstypescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

type Nullable<T> = { [K in keyof T]: T[K] | null };
04

Advanced Patterns

These patterns separate senior candidates from mid-level. They come up in system design discussions, library code reviews, and "improve this type" questions. You won't use all of these daily, but knowing they exist — and when to reach for them — signals deep TypeScript fluency.

🔥 Mapped Types & Key Remapping

Mapped types iterate over keys with [K in keyof T] to create new types. The as clause lets you rename keys during mapping — this is how you build patterns like auto-generating getter functions from an interface. The Capitalize intrinsic type transforms string literal types, so name becomes getName.

mapped.tstypescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person { name: string; age: number; }
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }

🔥 Template Literal Types

Template literal types construct string types from other types using backtick syntax at the type level. They're incredibly useful for typing event handler names (onClick, onFocus), CSS values (100px, 2rem), and API route patterns. Combined with Capitalize, Lowercase, and Uncapitalize, they give you string manipulation at compile time.

template.tstypescript
type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

type CSSUnit = "px" | "rem" | "em" | "%";
type CSSValue = `${number}${CSSUnit}`;
const width: CSSValue = "100px";

🔥 satisfies Operator

Added in TypeScript 4.9, satisfies solves a long-standing problem: you want to validate that a value matches a type, but you don't want TypeScript to widen the inferred type. With a type annotation (const x: Type = ...), TS widens to the annotated type and you lose narrow inference. With satisfies, TS keeps the narrow type while still checking conformance.

satisfies.tstypescript
type Colors = Record<string, [number, number, number] | string>;

// Without satisfies — type is widened
const colors1: Colors = { red: [255, 0, 0], green: "#00ff00" };

// With satisfies — keeps narrow inference
const colors2 = {
  red: [255, 0, 0],
  green: "#00ff00",
} satisfies Colors;
// colors2.red is [number, number, number]

🔥 Branded Types

TypeScript's structural typing means UserId and OrderId are interchangeable if both are string. Branded types add a phantom property (__brand) that only exists at the type level — it's never set at runtime. This creates nominal-like typing in a structural system, preventing you from accidentally passing an order ID where a user ID is expected.

branded.tstypescript
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function createUserId(id: string): UserId {
  return id as UserId;
}
function getUser(id: UserId) { /* ... */ }

const userId = createUserId("u_123");
getUser(userId);   // OK
// getUser("o_456" as OrderId); // Error

🔥 Function Overloads

Overloads let you declare multiple call signatures for a single function. Each overload gives callers a precise return type based on the input type. Without overloads, a function that accepts string | string[] would return number | number[] — callers wouldn't know which they got. With overloads, parse("42") returns number and parse(["1"]) returns number[].

overloads.tstypescript
function parse(input: string): number;
function parse(input: string[]): number[];
function parse(input: string | string[]): number | number[] {
  if (Array.isArray(input)) return input.map(Number);
  return Number(input);
}

parse("42");       // number
parse(["1", "2"]); // number[]
05

TypeScript with React

React + TypeScript is the most common interview combo. You need to type props, hooks, events, and context fluently — not just know the syntax, but understand why certain patterns exist. The key insight: React's component model maps naturally to TypeScript's generics and discriminated unions.

🔥 Typing Props & Children

Define props as a type alias (not interface, unless you need declaration merging). Use React.ReactNode for children — it covers strings, numbers, elements, fragments, and null. Avoid React.FC — it was removed from the default template in React 18 because it adds implicit children and makes generics harder.

props.tsxtypescript
type CardProps = {
  title: string;
  variant?: "default" | "outlined";
  children: React.ReactNode;
};

function Card({ title, variant = "default", children }: CardProps) {
  return <div className={variant}><h2>{title}</h2>{children}</div>;
}

🔥 Event Handlers

React provides typed event objects for every DOM event. The pattern is React.{EventType}<HTMLElementType>. The most common ones: ChangeEvent<HTMLInputElement> for inputs, FormEvent<HTMLFormElement> for form submission, and MouseEvent<HTMLButtonElement> for clicks. Always type the handler parameter, not the callback prop.

events.tsxtypescript
function SearchInput() {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
  };
  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} />
    </form>
  );
}

🔥 Generic Components

Generic components let the type flow from usage. A Select<T> component infers T from the options array, so onChange receives the correct type without the consumer specifying it. This is the pattern behind every typed form library, data table, and autocomplete component.

generic-select.tsxtypescript
type SelectProps<T> = {
  options: T[];
  value: T;
  onChange: (value: T) => void;
  getLabel: (item: T) => string;
};

function Select<T>({ options, value, onChange, getLabel }: SelectProps<T>) {
  return (
    <select value={String(value)}
      onChange={(e) => {
        const sel = options.find((o) => String(o) === e.target.value);
        if (sel) onChange(sel);
      }}>
      {options.map((opt) => (
        <option key={String(opt)} value={String(opt)}>{getLabel(opt)}</option>
      ))}
    </select>
  );
}

🔥 Typing Hooks

useState infers the type from the initial value, but when the initial value is null, you need an explicit generic: useState<User | null>(null). For useRef, the key distinction is DOM refs vs mutable refs — passing null as the initial value makes .current readonly (React manages it), while passing a non-null value makes it mutable (you manage it). useReducer pairs perfectly with discriminated union actions.

hooks.tsxtypescript
// useState
const [user, setUser] = useState<User | null>(null);

// useRef — DOM vs mutable
const inputRef = useRef<HTMLInputElement>(null);  // DOM
const timerRef = useRef<number>(0);               // mutable

// useReducer with discriminated union actions
type Action =
  | { type: "increment" }
  | { type: "set"; payload: number };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment": return state + 1;
    case "set":       return action.payload;
  }
}

🔥 Typing Context

The standard pattern: create context with null default, then build a custom hook that throws if the context is null. This avoids the undefined check at every usage site. The hook guarantees the return type is AuthContext (not AuthContext | null), so consumers never deal with null.

context.tsxtypescript
type AuthContext = {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
};

const AuthCtx = createContext<AuthContext | null>(null);

function useAuth(): AuthContext {
  const ctx = useContext(AuthCtx);
  if (!ctx) throw new Error("useAuth must be inside AuthProvider");
  return ctx;
}

🔥 Discriminated Union Props

This pattern makes impossible states unrepresentable. A button that's either a link (with href) or a button (with onClick) — never both. The onClick?: never on the link variant means TypeScript errors if you try to pass both. This is how design system libraries type polymorphic components.

disc-props.tsxtypescript
type ButtonProps =
  | { variant: "link"; href: string; onClick?: never }
  | { variant: "button"; onClick: () => void; href?: never };

function ActionButton(props: ButtonProps) {
  if (props.variant === "link") {
    return <a href={props.href}>Click</a>;
  }
  return <button onClick={props.onClick}>Click</button>;
}
06

Runtime vs Compile-time

TypeScript types are erased at compile time — they don't exist in the JavaScript output. This means you cannot use interfaces in typeof checks, switch statements, or any runtime logic. Understanding this boundary is critical: interviewers love asking "can you check if something is type X at runtime?" and the answer is always "not directly — you need runtime checks like in or discriminant properties."

🔥 Type Erasure

Interfaces, type aliases, generics, and type assertions all disappear after compilation. The compiled JavaScript has no trace of your types. This is why you can't do instanceof on a type alias — there's nothing to check against at runtime. Instead, use property checks ("meow" in animal) or discriminated unions with a literal discriminant field.

erasure.tstypescript
type Cat = { meow: () => void };
type Dog = { bark: () => void };

// Cannot use type in runtime check
// if (animal instanceof Cat) { ... } // ERROR

// Use "in" checks instead
function speak(animal: Cat | Dog) {
  if ("meow" in animal) animal.meow();
  else animal.bark();
}

🔥 Enums vs Unions

Enums are one of the few TypeScript features that generate runtime code — they compile to a JavaScript object with reverse mappings. This makes them non-tree-shakeable and adds bundle weight. const enum inlines values at compile time (no runtime object), but breaks with --isolatedModules which most bundlers require. The modern approach: use string literal unions with as const arrays when you need both the type and runtime values.

ApproachRuntime?Tree-shakeable?Recommendation
enumYes — generates JS objectNoAvoid in most cases
const enumNo — inlinedYesBreaks with --isolatedModules
Union typeNo — erasedYesPreferred approach
enums.tstypescript
// Avoid: enum generates runtime code
enum Direction { Up, Down, Left, Right }

// Prefer: union — zero runtime cost
type Direction = "up" | "down" | "left" | "right";

// If you need runtime values + type safety:
const DIRECTIONS = ["up", "down", "left", "right"] as const;
type Direction2 = (typeof DIRECTIONS)[number];

🔥 Declaration Files & Module Augmentation

Declaration files (.d.ts) provide type information without implementation — they're how @types/react works. declare module lets you add types for untyped libraries. Module augmentation extends existing types from libraries — this is how you add custom properties to Next.js's Session or Express's Request. declare global extends the global scope, like adding analytics to Window.

declarations.d.tstypescript
// .d.ts — type declarations
declare module "untyped-lib" {
  export function doSomething(input: string): number;
}

// Module augmentation
declare module "next-auth" {
  interface Session {
    user: { id: string; role: "admin" | "user" };
  }
}

// Global extension
declare global {
  interface Window {
    analytics: { track: (event: string) => void };
  }
}
07

Top 25 Interview Questions

Q:What is structural typing?

A: TypeScript uses structural typing (duck typing) — two types are compatible if their shapes match, regardless of name. This means { name: string } is assignable to any type expecting an object with a name: string property.

Q:Explain type vs interface.

A: Both define object shapes. interface supports declaration merging and extends. type supports unions, intersections, mapped types, and conditional types. Use interface for public APIs, type for everything else.

Q:What does strictNullChecks do?

A: When enabled, null and undefined are not assignable to other types. It forces you to handle null cases explicitly — essential for production code.

Q:Implement Pick<T, K> from scratch.

A: type MyPick<T, K extends keyof T> = { [P in K]: T[P] }. Uses a mapped type to iterate over keys K and maps each to its value type in T.

Q:unknown vs any — what's the difference?

A: any disables type checking. unknown is type-safe — you must narrow it before use. Always prefer unknown for values of uncertain type.

Q:Explain the never type.

A: never represents impossible values — functions that always throw, infinite loops, or exhaustive switch cases. It's the bottom type.

Q:What are discriminated unions?

A: A union where each member has a common literal property (discriminant) that TypeScript uses to narrow the type. Example: { ok: true; data: T } | { ok: false; error: Error }.

Q:How do conditional types work?

A: T extends U ? X : Y — if T is assignable to U, resolves to X, otherwise Y. They distribute over unions automatically.

Q:What does infer do?

A: infer declares a type variable inside a conditional type that TS infers from the matched pattern. Used to extract return types, array elements, promise values, etc.

Q:Explain mapped types.

A: { [K in keyof T]: ... } iterates over keys to create new types. You can add/remove modifiers (readonly, optional) and remap keys with 'as'.

Q:What is the satisfies operator?

A: satisfies validates a value matches a type without widening it. You keep the narrow inferred type while ensuring it conforms to a broader contract.

Q:How do you type a polymorphic 'as' prop?

A: Use generics with ElementType: type BoxProps<C extends ElementType> = { as?: C } & ComponentPropsWithoutRef<C>.

Q:What are template literal types?

A: They construct string types from other types: type Event = `on${Capitalize<'click' | 'focus'>}` produces 'onClick' | 'onFocus'.

Q:Explain as const.

A: as const makes values deeply readonly and narrows literals. const arr = [1, 2] as const gives readonly [1, 2]. Essential for deriving union types from arrays.

Q:What are branded types?

A: A pattern for nominal typing: type UserId = string & { __brand: 'UserId' }. Prevents mixing up IDs that are all strings at the structural level.

Q:How do you type useRef?

A: DOM ref: useRef<HTMLInputElement>(null) — .current is readonly. Mutable ref: useRef<number>(0) — .current is writable.

Q:What is declaration merging?

A: Two interfaces with the same name merge into one. This is how libraries extend global types. Type aliases do NOT merge — they error on duplicates.

Q:How do function overloads work?

A: Multiple signatures followed by one implementation. Each overload gives callers a precise return type. The implementation must be compatible with all overloads.

Q:Exclude vs Omit — what's the difference?

A: Exclude works on union types (removes members). Omit works on object types (removes keys). Exclude is for unions, Omit is for objects.

Q:How do you enforce exhaustive checks?

A: Use never in the default case: function assertNever(x: never): never { throw new Error('Unexpected'); }. If you miss a case, x won't be never and TS errors.

Q:What does keyof do?

A: keyof T produces a union of T's property names as string literal types. For { name: string; age: number }, keyof gives 'name' | 'age'.

Q:typeof at type level vs runtime?

A: Runtime typeof returns a string. Type-level typeof extracts the type of a value: type User = typeof myVariable.

Q:How do you type React context?

A: createContext<T | null>(null) with a custom hook that throws if null: const ctx = useContext(MyCtx); if (!ctx) throw new Error('Missing provider').

Q:What are index signatures?

A: { [key: string]: number } allows any string key with number values. Prefer Record<string, number> for readability.

Q:How do you handle untyped libraries?

A: Install @types/package if available. Otherwise create a .d.ts with declare module 'package-name' and add minimal declarations.

08

Last Day Revision Sheet

Quick Revision Cheat Sheet

type vs interface: interface for extendable shapes, type for unions/intersections/computed

unknown vs any: unknown is type-safe (must narrow), any opts out entirely

never: Bottom type — impossible values, exhaustive checks

Discriminated unions: Common literal discriminant field for narrowing

Generics: Type params for reusable code: function id<T>(x: T): T

Constraints: T extends { length: number } — restrict what T can be

infer: Extract types in conditional: T extends (...) => infer R ? R : never

Mapped types: { [K in keyof T]: ... } — transform every property

Template literals: `on${Capitalize<EventName>}` — build string types

satisfies: Validate without widening: { } satisfies Config

as const: Deep readonly + narrow literals: [1, 2] as const

Branded types: string & { __brand: 'UserId' } — nominal in structural system

Type guards: function isX(v: unknown): v is X — custom narrowing

keyof: Union of property names: keyof { a: 1; b: 2 } → 'a' | 'b'

typeof (type level): Extract type from value: type T = typeof myVariable

Enums → unions: Prefer type D = 'up' | 'down' over enum. Zero runtime cost.

React: useRef: DOM: useRef<HTMLElement>(null). Mutable: useRef<number>(0)

React: context: createContext<T | null>(null) + custom hook with null check

React: generic component: function Select<T>(props: SelectProps<T>)

Declaration merging: Two interfaces merge. Types don't — they error.