gitGood.dev
Back to Blog

Top 50 TypeScript Interview Questions (2026)

P
Patrick Wilson
41 min read

TypeScript has won. It's no longer a nice-to-have on your resume - it's the default for serious frontend and full-stack work. Every major framework ships with TypeScript support, and most companies have either migrated or are mid-migration.

But here's the thing: most developers use TypeScript at a surface level. They add : string to a few variables, sprinkle in some any when the compiler complains, and call it a day. Interviewers know this. The questions below are designed to separate developers who truly understand the type system from those who just survive it.

These 50 questions cover what actually comes up in real interviews - organized from fundamentals to advanced patterns. If you're also brushing up on JavaScript fundamentals, check out our top 50 JavaScript interview questions. And if you're preparing for React-specific questions, our React interview guide covers that in depth.

Let's get into it.


Type System Fundamentals (1-10)

These are the warm-up questions. Every TypeScript interview starts here. Get these wrong and the interview is effectively over.

1. What's the difference between type and interface?

Both define the shape of objects, but they have different capabilities and different design intentions.

Interfaces are extendable through declaration merging and are designed for defining object shapes and contracts. Type aliases can represent any type - unions, intersections, primitives, tuples - not just objects.

// Interface - extendable, mergeable
interface User {
  name: string;
  email: string;
}

interface User {
  age?: number; // declaration merging - this adds to the above
}

// Type - more flexible
type ID = string | number;
type UserOrAdmin = User | Admin;
type Pair<T> = [T, T];
type Callback = (data: string) => void;

Key differences: interfaces support declaration merging (types don't), types can represent unions/intersections/tuples/primitives (interfaces can't), and both work for object shapes and class implementation.

The real interview answer: "I use interface for public APIs and object contracts because they're extendable. I use type for everything else - unions, utility types, computed types. In practice, for most object shapes, either works fine."

2. Explain union types and intersection types.

Union types (|) mean "one of these types." Intersection types (&) mean "all of these types combined."

// Union - value is ONE of these
type Status = "loading" | "success" | "error";
type StringOrNumber = string | number;

function format(value: StringOrNumber): string {
  if (typeof value === "string") return value.toUpperCase();
  return value.toFixed(2);
}

// Intersection - value has ALL of these
type HasName = { name: string };
type HasEmail = { email: string };
type Contact = HasName & HasEmail;

const person: Contact = {
  name: "Alice",
  email: "alice@example.com"
  // must have BOTH name and email
};

Think of unions as "or" and intersections as "and." Unions widen the set of possible values. Intersections narrow it by combining constraints.

3. What is type narrowing and how does it work?

Type narrowing is how TypeScript refines a broad type into a more specific one inside a code block. It happens automatically when you use control flow checks.

function process(value: string | number | null) {
  // value is string | number | null here

  if (value === null) {
    return; // value is null in this block
  }
  // value is string | number here (null eliminated)

  if (typeof value === "string") {
    console.log(value.toUpperCase()); // value is string
  } else {
    console.log(value.toFixed(2)); // value is number
  }
}

TypeScript narrows through typeof, instanceof, truthiness checks, equality checks, the in operator, and discriminated union tag checks.

4. What are type guards and how do you write custom ones?

Type guards are functions that tell TypeScript "if this returns true, the value is this specific type." Built-in ones include typeof and instanceof. Custom ones use the is keyword.

// Built-in type guards
typeof value === "string"   // narrows to string
value instanceof Date        // narrows to Date

// Custom type guard
interface Fish { swim(): void; }
interface Bird { fly(): void; }

function isFish(animal: Fish | Bird): animal is Fish {
  return (animal as Fish).swim !== undefined;
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    animal.swim(); // TypeScript knows it's Fish
  } else {
    animal.fly();  // TypeScript knows it's Bird
  }
}

Custom type guards are essential when you're working with data from APIs, config objects, or any situation where TypeScript can't automatically narrow the type.

The real interview answer: "Custom type guards give you runtime validation with compile-time type narrowing. They're the bridge between TypeScript's static types and the untyped data you get from the outside world."

5. What are literal types and how are they useful?

Literal types represent exact values, not just categories. Instead of string, you can say "admin" or "user". Instead of number, you can say 200 or 404.

// String literal types
type Direction = "north" | "south" | "east" | "west";

function move(direction: Direction) {
  // only accepts these exact four strings
}

move("north"); // ok
move("up");    // error: Argument of type '"up"' is not assignable

// Numeric literal types
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;

// Boolean literal types
type True = true;

// Template literal types (TS 4.1+)
type EventName = `on${Capitalize<"click" | "hover" | "focus">}`;
// "onClick" | "onHover" | "onFocus"

Literal types are the foundation of discriminated unions, which are one of the most powerful patterns in TypeScript.

6. Enums vs const enums vs union types - when do you use each?

This one comes up a lot because there's real disagreement in the community.

// Regular enum - generates runtime code
enum Direction {
  North,
  South,
  East,
  West
}
// Compiled to: { 0: "North", 1: "South", North: 0, South: 1, ... }

// Const enum - inlined at compile time, no runtime object
const enum HttpStatus {
  OK = 200,
  NotFound = 404,
  ServerError = 500
}
// Uses are replaced with literal values: 200, 404, 500

// Union type - no runtime cost, best type safety
type Direction = "north" | "south" | "east" | "west";

Regular enums generate runtime objects. Const enums are inlined (erased at compile time). Union types have zero runtime cost and are the most common choice in modern TypeScript.

The real interview answer: "I prefer string union types for most cases. They're simpler, have zero runtime overhead, and give better error messages. I reach for enums only when I specifically need runtime iteration over the values."

7. What's the difference between any, unknown, and never?

These three special types sit at the extremes of the type system.

// any - opts out of type checking entirely
let a: any = 42;
a.foo.bar.baz; // no error - TypeScript gives up

// unknown - type-safe alternative to any
let b: unknown = 42;
b.toFixed(); // error: Object is of type 'unknown'

if (typeof b === "number") {
  b.toFixed(); // ok - narrowed to number
}

// never - represents values that never occur
function throwError(msg: string): never {
  throw new Error(msg);
}

function infinite(): never {
  while (true) {}
}

// never in exhaustive checks
type Shape = "circle" | "square";
function area(shape: Shape) {
  switch (shape) {
    case "circle": return /* ... */;
    case "square": return /* ... */;
    default:
      const exhaustive: never = shape; // error if a case is missing
      return exhaustive;
  }
}

The hierarchy: any is the escape hatch (avoid it). unknown is "I don't know what this is yet" (use it for untyped inputs). never is "this should be impossible" (use it for exhaustiveness checking).

8. How do you handle nullable types in TypeScript?

With strictNullChecks enabled (and it should always be enabled), null and undefined are their own types and must be explicitly included.

// Without strict null checks: this compiles but crashes at runtime
// With strict null checks: TypeScript catches this
function getLength(str: string | null): number {
  // str.length; // error: str is possibly null

  // Option 1: type guard
  if (str !== null) {
    return str.length;
  }
  return 0;

  // Option 2: optional chaining + nullish coalescing
  return str?.length ?? 0;

  // Option 3: non-null assertion (use sparingly)
  return str!.length; // tells TS "trust me, it's not null"
}

The ! non-null assertion is a code smell in most cases. Prefer actual narrowing.

9. What is structural typing and how is it different from nominal typing?

TypeScript uses structural typing (also called duck typing). Types are compatible if their structure matches - the names don't matter.

interface Point {
  x: number;
  y: number;
}

interface Coordinate {
  x: number;
  y: number;
}

const point: Point = { x: 1, y: 2 };
const coord: Coordinate = point; // perfectly fine - same structure

// But direct object literals trigger excess property checking
interface Named { name: string }
const named: Named = { name: "Alice", age: 30 }; // error - excess property

This is different from languages like Java or C# which use nominal typing - where types must share the same name or inheritance chain to be compatible.

The real interview answer: "Structural typing makes TypeScript incredibly flexible with JavaScript patterns, but it means you can accidentally pass the wrong object if it happens to have the right shape. Branded types solve this when you need nominal-like behavior."

10. What is the readonly modifier and how does it work?

readonly prevents reassignment of properties after initialization. It's TypeScript-only enforcement - it doesn't exist at runtime.

interface Config {
  readonly apiUrl: string;
  readonly maxRetries: number;
}

const config: Config = { apiUrl: "https://api.example.com", maxRetries: 3 };
config.apiUrl = "something"; // error: Cannot assign to 'apiUrl'

// Readonly arrays and tuples
const numbers: readonly number[] = [1, 2, 3];
numbers.push(4); // error: Property 'push' does not exist on type 'readonly number[]'

// as const makes everything deeply readonly with literal types
const routes = {
  home: "/",
  about: "/about",
  blog: "/blog"
} as const;
// type is { readonly home: "/"; readonly about: "/about"; readonly blog: "/blog" }

readonly is shallow - it won't prevent mutation of nested objects. For deep immutability, you need recursive readonly types or as const.


Generics (11-18)

Generics are where TypeScript gets powerful - and where many developers start struggling. Interviewers use these questions to gauge your depth.

11. What are generics and why do they matter?

Generics let you write code that works with any type while preserving type information. Without generics, you'd have to use any or duplicate code for every type.

// Without generics - loses type info
function firstElement(arr: any[]): any {
  return arr[0];
}
const result = firstElement([1, 2, 3]); // type is any

// With generics - preserves type info
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}
const num = firstElement([1, 2, 3]);       // type is number
const str = firstElement(["a", "b", "c"]); // type is string

Generics show up everywhere in real code: arrays (Array<T>), promises (Promise<T>), React components (useState<T>), API responses, and utility types.

12. How do generic constraints work?

Constraints limit what types a generic can accept. Use extends to require specific properties or types.

// With constraint - T must have a length property
function logLength<T extends { length: number }>(value: T) {
  console.log(value.length); // ok
}

logLength("hello");     // ok - string has .length
logLength([1, 2, 3]);   // ok - array has .length
logLength(42);           // error - number has no .length

// Constraining with keyof
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // returns string
getProperty(user, "foo");  // error: "foo" is not a key of user

The getProperty pattern is one of the most common in TypeScript. It gives you type-safe property access on arbitrary objects.

13. What are conditional types?

Conditional types choose between two types based on a condition. They follow the pattern T extends U ? X : Y.

// Basic conditional type
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

// Practical example: extracting return types
type UnpackPromise<T> = T extends Promise<infer R> ? R : T;

type X = UnpackPromise<Promise<string>>; // string
type Y = UnpackPromise<number>;          // number

// Distributive conditional types - unions distribute automatically
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[] (NOT (string | number)[])

Conditional types are the backbone of most utility types and advanced type-level programming.

14. What does the infer keyword do?

infer declares a type variable inside a conditional type. It lets you "extract" a type from within another type.

// Extract the return type of a function
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type A = MyReturnType<() => string>;           // string
type B = MyReturnType<(x: number) => boolean>; // boolean

// Extract element type from an array
type ElementType<T> = T extends (infer E)[] ? E : T;

type C = ElementType<string[]>;  // string
type D = ElementType<number>;    // number

// Extract the type of a Promise
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

type E = Awaited<Promise<Promise<string>>>; // string (recursive!)

infer is like pattern matching for types. Once you understand it, you can build incredibly powerful type utilities.

15. What are mapped types?

Mapped types create new types by transforming each property of an existing type. They iterate over keys using in.

// Basic mapped type - make all properties optional
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Make all properties readonly
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Make all properties nullable
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

// Rename keys using `as`
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 }

// Filter properties by type
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type StringProps = OnlyStrings<Person>;
// { name: string }

Mapped types are one of TypeScript's most powerful features. Combined with template literal types, they enable incredible API designs.

16. What's the difference between keyof and indexed access types?

keyof gives you a union of all property names. Indexed access (T[K]) gives you the type of a specific property.

interface User {
  id: number;
  name: string;
  email: string;
  isAdmin: boolean;
}

// keyof - gets the keys as a union
type UserKeys = keyof User; // "id" | "name" | "email" | "isAdmin"

// Indexed access - gets the type of a property
type NameType = User["name"];        // string
type IdOrName = User["id" | "name"]; // number | string

// Combined pattern - type-safe property access
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  keys.forEach(key => result[key] = obj[key]);
  return result;
}

const user: User = { id: 1, name: "Alice", email: "a@b.com", isAdmin: false };
const subset = pick(user, ["name", "email"]);
// type is Pick<User, "name" | "email"> = { name: string; email: string }

These two features together are what make TypeScript's type system truly expressive. You'll use them constantly in utility types and generic functions.

17. How do you use multiple type parameters effectively?

Multiple generics let you capture relationships between different parts of a function signature.

// Two related types
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}

const lengths = map(["hello", "world"], s => s.length);
// T = string, U = number, result is number[]

// Constraining one parameter based on another
function assign<T extends object, K extends keyof T>(
  obj: T,
  key: K,
  value: T[K]
): void {
  obj[key] = value;
}

assign(user, "name", "Bob");    // ok
assign(user, "name", 42);       // error: number not assignable to string
assign(user, "invalid", "foo"); // error: "invalid" not a key of User

The real interview answer: "The trick with multiple generics is that each one should represent a meaningful type relationship. If a generic parameter is only used once, you probably don't need it - just use the concrete type."

18. What is the extends keyword in different contexts?

extends does three completely different things depending on context. This trips up a lot of developers.

// 1. Interface inheritance
interface Animal {
  name: string;
}
interface Dog extends Animal {
  breed: string;
}

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

// 3. Conditional types
type IsArray<T> = T extends any[] ? true : false;

// All three use 'extends' but mean different things:
// - Inheritance: "includes all properties of"
// - Constraint: "must be assignable to"
// - Conditional: "is assignable to" (like a type-level ternary)

In generic constraints and conditional types, extends means "is assignable to" - think of it as a subset check, not classical inheritance.


Utility Types (19-26)

TypeScript ships with a bunch of built-in utility types. Interviewers love asking about these because they reveal whether you use TypeScript idiomatically.

19. How do Partial<T> and Required<T> work?

Partial makes all properties optional. Required makes all properties required. They're opposites.

interface User {
  id: number;
  name: string;
  email: string;
  bio?: string;
}

// Partial - great for update functions
type UpdateUser = Partial<User>;
// { id?: number; name?: string; email?: string; bio?: string }

function updateUser(id: number, changes: Partial<User>) {
  // only pass the fields you want to change
}
updateUser(1, { name: "New Name" }); // ok - only updating name

// Required - makes optional properties required
type CompleteUser = Required<User>;
// { id: number; name: string; email: string; bio: string }

// Under the hood
type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] }; // -? removes optionality

Partial<T> is probably the most-used utility type in real codebases. You'll see it in every update/patch API.

20. How do Pick<T, K> and Omit<T, K> work?

Pick creates a type with only the specified properties. Omit creates a type without the specified properties.

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

// Pick - select specific properties
type PublicUser = Pick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

// Omit - exclude specific properties
type UserWithoutPassword = Omit<User, "password">;
// { id: number; name: string; email: string; createdAt: Date }

// Under the hood
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

These are everywhere in API design. You pick what to expose publicly and omit what's internal.

21. What does Record<K, V> do and when do you use it?

Record creates an object type with keys of type K and values of type V. It's the go-to for typed dictionaries and lookup maps.

// Simple lookup map
type StatusLabels = Record<"active" | "inactive" | "pending", string>;

const labels: StatusLabels = {
  active: "Active",
  inactive: "Inactive",
  pending: "Pending"
};

// Under the hood
type MyRecord<K extends keyof any, V> = { [P in K]: V };

The real interview answer: "I use Record instead of { [key: string]: T } because it's more readable and the key type is explicitly part of the signature. When keys are known, I use a union as the key type for exhaustive checking."

22. How do Exclude<T, U> and Extract<T, U> work?

These operate on union types. Exclude removes members. Extract keeps members.

type AllColors = "red" | "green" | "blue" | "yellow";

// Exclude - remove types from a union
type WarmColors = Exclude<AllColors, "blue" | "green">;
// "red" | "yellow"

// Extract - keep only matching types from a union
type CoolColors = Extract<AllColors, "blue" | "green" | "purple">;
// "blue" | "green"

// Under the hood - using distributive conditional types
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;

Understanding these helps you understand how distributive conditional types work in general.

23. What are ReturnType<T> and Parameters<T>?

These extract type information from function signatures - the return type and parameter types respectively.

function createUser(name: string, age: number): { id: number; name: string; age: number } {
  return { id: Math.random(), name, age };
}

// ReturnType - extracts the return type
type NewUser = ReturnType<typeof createUser>;
// { id: number; name: string; age: number }

// Parameters - extracts parameter types as a tuple
type CreateUserParams = Parameters<typeof createUser>;
// [name: string, age: number]

// Useful for wrapping functions
function withLogging<T extends (...args: any[]) => any>(
  fn: T
): (...args: Parameters<T>) => ReturnType<T> {
  return (...args) => {
    console.log("Calling with:", args);
    const result = fn(...args);
    console.log("Result:", result);
    return result;
  };
}

These are invaluable when you're building wrappers, decorators, or middleware - anywhere you need to preserve the type signature of another function.

24. What is NonNullable<T> and when do you use it?

NonNullable removes null and undefined from a type. Simple but extremely useful.

type MaybeString = string | null | undefined;

type DefiniteString = NonNullable<MaybeString>;
// string

// NonNullable in generic contexts
function assertDefined<T>(value: T): NonNullable<T> {
  if (value === null || value === undefined) {
    throw new Error("Value must be defined");
  }
  return value as NonNullable<T>;
}

const maybeName: string | null = getName();
const name = assertDefined(maybeName); // type is string

// Under the hood
type MyNonNullable<T> = T extends null | undefined ? never : T;

25. How do you build custom utility types?

This is where interview questions get interesting. Building your own utility types shows real TypeScript mastery.

// DeepPartial - recursive Partial
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
}

// Can partially update nested properties
const update: DeepPartial<Config> = {
  database: {
    credentials: {
      password: "new-password"
    }
  }
};

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

The real interview answer: "Custom utility types combine mapped types, conditional types, and infer to create reusable type transformations. The key is understanding that you can recurse, filter, and transform at the type level just like you would with runtime data."

26. What is Awaited<T> and how does it handle nested Promises?

Awaited<T> unwraps the resolved type of a Promise, handling nested Promises recursively. Added in TypeScript 4.5.

type A = Awaited<Promise<string>>;           // string
type B = Awaited<Promise<Promise<number>>>;  // number (deeply unwraps)
type C = Awaited<string>;                    // string (non-promise passes through)
type D = Awaited<string | Promise<number>>;  // string | number

// Why it matters - typing async functions correctly
async function fetchUser(): Promise<User> {
  const response = await fetch("/api/user");
  return response.json();
}

type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
// User (not Promise<User>)

// Simplified implementation
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;

Advanced Types (27-35)

This section separates senior developers from mid-level ones. If you can handle these questions fluently, you'll impress most interviewers.

27. What are template literal types?

Template literal types let you create string types using template syntax. They combine with union types to generate every possible combination.

// Basic template literal type
type Greeting = `Hello, ${string}`;
const g: Greeting = "Hello, World"; // ok
const h: Greeting = "Goodbye";     // error

// Combining with unions - generates all combinations
type Color = "red" | "blue";
type Size = "small" | "large";
type Variant = `${Color}-${Size}`;
// "red-small" | "red-large" | "blue-small" | "blue-large"

// Event handler pattern
type DOMEvents = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<DOMEvents>}`;
// "onClick" | "onFocus" | "onBlur"

// Built-in string manipulation: Uppercase, Lowercase, Capitalize, Uncapitalize

Template literal types are incredibly powerful for creating APIs that catch typos and invalid patterns at compile time.

28. What are discriminated unions and why are they so important?

Discriminated unions (also called tagged unions) are one of the most useful patterns in TypeScript. Each member of the union has a common property (the "discriminant") with a literal type.

// The discriminant is the 'type' property
type Shape =
  | { type: "circle"; radius: number }
  | { type: "rectangle"; width: number; height: number }
  | { type: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.type) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height; // TS knows width and height exist
    case "triangle":
      return 0.5 * shape.base * shape.height;
  }
}

// Real-world: API response handling
type ApiResult<T> =
  | { status: "success"; data: T }
  | { status: "error"; error: string; code: number }
  | { status: "loading" };

function handleResult<T>(result: ApiResult<T>) {
  switch (result.status) {
    case "success":
      console.log(result.data); // TS knows data exists
      break;
    case "error":
      console.error(result.error, result.code); // TS knows error and code exist
      break;
    case "loading":
      console.log("Loading...");
      break;
  }
}

The real interview answer: "Discriminated unions replace most uses of class hierarchies in TypeScript. They give you exhaustive pattern matching, excellent type narrowing, and they're much more composable than inheritance. I use them for state machines, API responses, and action types."

29. What are branded types (nominal types)?

Since TypeScript uses structural typing, two types with the same structure are interchangeable. Branded types add a phantom property to make structurally identical types incompatible.

// Problem: these are the same type structurally
type UserId = string;
type PostId = string;

function getUser(id: UserId) { /* ... */ }

const postId: PostId = "post-123";
getUser(postId); // no error! But this is a bug.

// Solution: branded types
type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;

// Constructor functions to create branded values
function userId(id: string): UserId {
  return id as UserId;
}

function postId(id: string): PostId {
  return id as PostId;
}

function getUser(id: UserId) { /* ... */ }

const uid = userId("user-123");
const pid = postId("post-456");

getUser(uid); // ok
getUser(pid); // error! PostId is not assignable to UserId

Branded types are a pattern, not a built-in feature. They use the as assertion at creation time but provide safety everywhere else.

30. What are recursive types?

Recursive types reference themselves in their definition. They're essential for tree structures, nested data, and JSON-like types.

// JSON type
type Json =
  | string
  | number
  | boolean
  | null
  | Json[]
  | { [key: string]: Json };

const data: Json = {
  name: "Alice",
  age: 30,
  hobbies: ["reading", "coding"],
  address: {
    city: "Portland",
    zip: 97201
  }
};

// Tree structure
type TreeNode<T> = {
  value: T;
  children: TreeNode<T>[];
};

TypeScript has a recursion depth limit (about 50 levels), so deeply recursive types can hit the ceiling. In practice, this is rarely a problem.

31. What is variance and how does it affect type compatibility?

Variance describes how type relationships change with generics. This is one of the harder concepts but it comes up in senior-level interviews.

// Covariance - preserves the direction of assignability
// Array<T> is covariant in T (with readonly arrays)
type Animal = { name: string };
type Dog = Animal & { breed: string };

const dogs: readonly Dog[] = [{ name: "Rex", breed: "Lab" }];
const animals: readonly Animal[] = dogs; // ok - Dog[] assignable to Animal[]

// Contravariance - reverses the direction
// Function parameters are contravariant
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;

const handleAnimal: AnimalHandler = (a) => console.log(a.name);
const handleDog: DogHandler = handleAnimal; // ok - AnimalHandler works as DogHandler
// handleAnimal can handle any Animal, so it can definitely handle a Dog

// In and out modifiers (TS 4.7+)
interface Producer<out T> {  // covariant - only produces T
  get(): T;
}

interface Consumer<in T> {   // contravariant - only consumes T
  accept(value: T): void;
}

interface Transform<in I, out O> { // mixed
  process(input: I): O;
}

The real interview answer: "Covariance means a Dog producer can substitute for an Animal producer. Contravariance means an Animal consumer can substitute for a Dog consumer. TypeScript handles most of this automatically, but understanding it helps when you get weird assignability errors with generics."

32. How does the satisfies operator work?

satisfies (TypeScript 4.9+) validates that an expression matches a type without widening it. You get validation AND the most specific inferred type.

// Problem with type annotation - widens the type
const routes: Record<string, { path: string }> = {
  home: { path: "/" },
  about: { path: "/about" }
};
routes.home;    // ok
routes.invalid; // also ok (no error!) - keys are just string

// Problem with as const - no validation
const routes2 = {
  home: { path: "/" },
  about: { path: "/abut" } // typo - no validation against a schema
} as const;

// satisfies - best of both worlds
const routes3 = {
  home: { path: "/" },
  about: { path: "/about" }
} satisfies Record<string, { path: string }>;

routes3.home;    // ok - TypeScript knows "home" exists
routes3.invalid; // error - "invalid" doesn't exist (narrow type preserved)

satisfies is one of the most impactful additions to TypeScript in recent years. Use it whenever you want type checking without losing specificity.

33. How do typeof and keyof differ in type context vs value context?

typeof has two meanings in TypeScript. In value context it's JavaScript's runtime operator. In type context it extracts the type of a variable.

// typeof in value context (runtime) - returns a string
console.log(typeof "hello"); // "string"
console.log(typeof 42);      // "number"

// typeof in type context - extracts the TypeScript type
const config = { apiUrl: "https://api.example.com", retries: 3 };
type Config = typeof config;
// { apiUrl: string; retries: number }

// Combined with keyof
type ConfigKeys = keyof typeof config;
// "apiUrl" | "retries"

// Extracting function types
function createUser(name: string, age: number) {
  return { name, age, id: crypto.randomUUID() };
}

type CreateUserFn = typeof createUser;
// (name: string, age: number) => { name: string; age: number; id: string }

type NewUser = ReturnType<typeof createUser>;
// { name: string; age: number; id: string }

The typeof + keyof combo is especially powerful when you want to derive types from runtime values rather than defining types separately.

34. How do you use asserts in assertion functions?

Assertion functions narrow types by throwing if a condition isn't met. They use the asserts keyword in their return type.

// Basic assertion function
function assertString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}

function processInput(input: unknown) {
  assertString(input);
  // After the assertion, TypeScript knows input is string
  console.log(input.toUpperCase());
}

// Assert non-null
function assertDefined<T>(value: T | null | undefined, name: string): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error(`${name} must be defined`);
  }
}

const element = document.getElementById("app");
assertDefined(element, "App element");
element.innerHTML = "Hello"; // no null check needed

// Assert condition
function assert(condition: boolean, message: string): asserts condition {
  if (!condition) {
    throw new Error(message);
  }
}

function divide(a: number, b: number): number {
  assert(b !== 0, "Division by zero");
  return a / b; // TypeScript knows b is not 0
}

Assertion functions are the type-narrowing equivalent of if checks, but they throw instead of branching. They're great for validating preconditions at the top of functions.

35. What are NoInfer<T> and other intrinsic types?

NoInfer (TypeScript 5.4+) prevents a type parameter from being inferred from a specific position. It forces inference to happen elsewhere.

// With NoInfer - T is only inferred from states, not initial
function createFSM<T extends string>(config: {
  initial: NoInfer<T>;
  states: T[];
}) {}

createFSM({
  initial: "idel", // error! "idel" is not in the union
  states: ["idle", "loading", "error"]
});

// Another example: default values
function getOrDefault<T>(values: T[], defaultValue: NoInfer<T>): T {
  return values.length > 0 ? values[0] : defaultValue;
}

getOrDefault([1, 2, 3], "fallback"); // error - string not assignable to number
// Without NoInfer, T would widen to string | number

NoInfer is a newer addition but it solves a long-standing pain point with generic inference. It's especially useful in config objects and builder patterns.


TypeScript with React (36-42)

React and TypeScript are practically inseparable in 2026. These questions come up in every frontend interview.

36. How do you type React component props?

There are several approaches, but some are clearly better than others.

// Preferred: interface for props
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary" | "danger";
  disabled?: boolean;
  children?: React.ReactNode;
}

function Button({ label, onClick, variant = "primary", disabled = false }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant}`}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
}

// Extending HTML element props
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

The real interview answer: "I always use interfaces for props, extend native HTML attributes when wrapping elements, and use React.ReactNode for children. I never use React.FC - it adds complexity without benefit in modern React."

37. How do you type React hooks?

Most hooks can infer types, but sometimes you need explicit annotations.

// useState - infer when possible, annotate when needed
const [count, setCount] = useState(0); // inferred as number
const [user, setUser] = useState<User | null>(null); // explicit for nullable
const [items, setItems] = useState<string[]>([]); // explicit for empty arrays

// useRef - three different patterns
const inputRef = useRef<HTMLInputElement>(null);       // DOM ref (readonly .current)
const countRef = useRef<number>(0);                     // mutable ref
const callbackRef = useRef<(() => void) | null>(null); // nullable ref

// useReducer with discriminated union
type State = { count: number; error: string | null };
type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "set"; payload: number }
  | { type: "error"; message: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment": return { ...state, count: state.count + 1 };
    case "decrement": return { ...state, count: state.count - 1 };
    case "set": return { ...state, count: action.payload };
    case "error": return { ...state, error: action.message };
  }
}

const [state, dispatch] = useReducer(reducer, { count: 0, error: null });

// useMemo and useCallback - types are inferred
const doubled = useMemo(() => count * 2, [count]); // number
const handleClick = useCallback((e: React.MouseEvent) => {
  console.log(e.clientX);
}, []);

38. How do you type React context?

Context typing requires a bit of setup but pays off in type safety across the component tree.

// Define the context shape
interface AuthContext {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

// Create with a default value or undefined
const AuthContext = createContext<AuthContext | undefined>(undefined);

// Custom hook with type guard
function useAuth(): AuthContext {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
}

// Usage - fully typed, no null checks needed
function Dashboard() {
  const { user, logout } = useAuth();
  // user is User | null, logout is () => void
}

The pattern of createContext(undefined) plus a custom hook that throws is the standard approach in 2026. It avoids having to provide a meaningless default value.

39. How do you type event handlers in React?

React has specific event types that differ from native DOM events. Knowing which to use is important.

// Mouse events
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
  console.log(e.clientX, e.clientY);
}

// Form events
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const formData = new FormData(e.currentTarget);
}

// Change events
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  console.log(e.target.value);
}

// Keyboard events
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
  if (e.key === "Enter") {
    console.log("Enter pressed");
  }
}

The generic parameter (e.g., HTMLButtonElement) determines what e.currentTarget is typed as. This matters when you need to access element-specific properties.

40. How do you build generic React components?

Generic components let you create reusable UI that preserves type information about the data it handles.

// Generic list component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyMessage?: string;
}

function List<T>({ items, renderItem, keyExtractor, emptyMessage }: ListProps<T>) {
  if (items.length === 0) {
    return <p>{emptyMessage ?? "No items"}</p>;
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// Usage - T is inferred from items
<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>} // user is typed as User
  keyExtractor={(user) => user.id}
/>


The real interview answer: "Generic components are the backbone of any component library. The key insight is that the generic parameter flows from props - so items: T[] combined with the actual data tells TypeScript what T is throughout the component."

41. How do you type higher-order components and render props?

These patterns are less common with hooks, but they still appear in codebases and interviews.

// Render props pattern
interface DataFetcherProps<T> {
  url: string;
  children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
}

function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return <>{children(data, loading, error)}</>;
}

42. How do you type forwardRef with generics?

forwardRef and generics don't play nicely together out of the box. Here's how to handle it.

// Basic forwardRef typing
const Input = forwardRef<HTMLInputElement, InputProps>(
  function Input({ label, error, ...rest }, ref) {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} {...rest} />
        {error && <span>{error}</span>}
      </div>
    );
  }
);

// React 19 approach - ref is just a prop, no forwardRef needed!
// No forwardRef needed!
interface InputProps {
  label: string;
  ref?: React.Ref<HTMLInputElement>;
}

function Input({ label, ref }: InputProps) {
  return <input ref={ref} />;
}

In React 19, ref is just a regular prop, which eliminates the need for forwardRef in most cases. This is a significant simplification for TypeScript typing.


Configuration and Tooling (43-46)

These questions show you understand TypeScript beyond just writing code.

43. What are the most important tsconfig options?

There are dozens of options, but these are the ones that matter most.

{
  "compilerOptions": {
    // Type checking - turn ALL of these on
    "strict": true,              // enables all strict checks
    "noUncheckedIndexedAccess": true, // array/object access returns T | undefined
    "noUnusedLocals": true,      // error on unused variables
    "noUnusedParameters": true,  // error on unused function params
    "exactOptionalPropertyTypes": true, // distinguishes missing from undefined

    // Module resolution
    "module": "esnext",          // use ES modules
    "moduleResolution": "bundler", // for modern bundlers (Vite, webpack, etc.)
    "resolveJsonModule": true,   // allow importing .json files
    "isolatedModules": true,     // required for most build tools

    // Output
    "target": "es2022",
    "lib": ["dom", "es2023"],
    "jsx": "react-jsx",

    // Interop
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  }
}

The real interview answer: "Always start with strict: true and add noUncheckedIndexedAccess. These two alone catch the majority of runtime errors. For module resolution, bundler mode is the right choice for most web projects in 2026."

44. What is strict mode and what does it enable?

"strict": true is a shorthand that enables a bundle of strict checks. Here's what each one does.

// strictNullChecks - null and undefined are their own types
let name: string = null; // error with strictNullChecks

// strictFunctionTypes - function parameters are checked contravariantly
type Handler = (event: Event) => void;
const mouseHandler: Handler = (e: MouseEvent) => {}; // error - unsafe

// strictBindCallApply - checks bind/call/apply arguments
// strictPropertyInitialization - class properties must be initialized
// noImplicitAny - must explicitly annotate when type can't be inferred
// noImplicitThis - 'this' must have a type
// useUnknownInCatchVariables - catch clause is unknown, not any
// alwaysStrict - emits "use strict" in every file

There's no good reason to have strict mode off in a new project. Existing projects can migrate incrementally with // @ts-expect-error comments.

45. How does module resolution work in TypeScript?

Module resolution determines how TypeScript finds the file for an import statement. There are several strategies.

// Import statement
import { format } from "date-fns";
import { Button } from "@/components/Button";
import { helper } from "./utils";

// moduleResolution options:
// "node"     - classic Node.js resolution
// "node16"   - Node.js ESM + CJS resolution
// "bundler"  - for modern bundlers (Vite, webpack, turbopack)

// Type-only imports (no runtime cost)
import type { User } from "./types";
import { type User, createUser } from "./users";

The bundler module resolution strategy is the right choice for most web projects. It aligns with how modern bundlers actually resolve imports.

46. How do declaration files (.d.ts) work?

Declaration files provide type information for JavaScript code that doesn't have types built in. They contain only type declarations - no implementations.

// global.d.ts - augmenting global types
declare global {
  interface Window {
    analytics: {
      track: (event: string, data?: Record<string, unknown>) => void;
    };
  }
}

// module declaration for untyped packages
declare module "legacy-library" {
  export function doSomething(input: string): number;
  export interface Config {
    debug: boolean;
    timeout: number;
  }
}

// CSS modules declaration
declare module "*.module.css" {
  const classes: Record<string, string>;
  export default classes;
}

// Extending existing module types (declaration merging)
declare module "express" {
  interface Request {
    user?: { id: string; role: "admin" | "user" };
  }
}

The @types scope on npm is a massive collection of community-maintained declaration files. When you install @types/react, you're getting declaration files that describe React's API.


Modern TypeScript (47-50)

These are the cutting-edge features that show you're keeping up with the language.

47. How do const assertions work and when do you use them?

as const makes TypeScript infer the most specific (literal) types possible instead of widening to general types.

// Without as const - types are widened
const config = {
  endpoint: "https://api.example.com",
  retries: 3,
  methods: ["GET", "POST"]
};
// type: { endpoint: string; retries: number; methods: string[] }

// With as const - types are narrow and readonly
const config = {
  endpoint: "https://api.example.com",
  retries: 3,
  methods: ["GET", "POST"]
} as const;
// type: { readonly endpoint: "https://api.example.com"; readonly retries: 3; readonly methods: readonly ["GET", "POST"] }

// Creating enum-like patterns
const HttpMethod = {
  GET: "GET",
  POST: "POST",
  PUT: "PUT",
  DELETE: "DELETE"
} as const;

type HttpMethod = typeof HttpMethod[keyof typeof HttpMethod];
// "GET" | "POST" | "PUT" | "DELETE"

// Array to union type
const statuses = ["active", "inactive", "pending"] as const;
type Status = typeof statuses[number];
// "active" | "inactive" | "pending"

The real interview answer: "I use as const in two main scenarios: creating union types from arrays (so I have both a runtime value and a type), and creating config objects where I want TypeScript to preserve the exact values."

48. What is the using keyword and how does resource management work?

The using keyword (TypeScript 5.2+) implements the TC39 Explicit Resource Management proposal. It automatically disposes of resources when they go out of scope.

// The Disposable interface
interface Disposable {
  [Symbol.dispose](): void;
}

interface AsyncDisposable {
  [Symbol.asyncDispose](): Promise<void>;
}

// Creating a disposable resource
class DatabaseConnection implements Disposable {
  constructor(private url: string) {
    console.log(`Connected to ${url}`);
  }

  query(sql: string): unknown[] {
    return []; // execute query
  }

  [Symbol.dispose]() {
    console.log(`Disconnected from ${this.url}`);
  }
}

// using - automatically calls dispose when scope exits
function runQuery() {
  using db = new DatabaseConnection("postgres://localhost/mydb");
  const results = db.query("SELECT * FROM users");
  return results;
  // db[Symbol.dispose]() is called automatically here
}

// Works with async too: await using file = await openFile("data.csv");

using replaces try/finally patterns for resource cleanup. It's the TypeScript equivalent of Python's with statement or C#'s using statement.

49. How do decorators work in TypeScript 5.x?

TypeScript 5.0 shipped support for the TC39 Stage 3 decorators proposal. These are different from the experimental decorators used in older TypeScript and Angular.

// Class method decorator
function log(
  target: any,
  context: ClassMethodDecoratorContext
) {
  const methodName = String(context.name);
  return function (this: any, ...args: any[]) {
    console.log(`Calling ${methodName} with`, args);
    const result = target.call(this, ...args);
    console.log(`${methodName} returned`, result);
    return result;
  };
}

class Calculator {
  @log
  add(a: number, b: number): number {
    return a + b;
  }
}

const calc = new Calculator();
calc.add(2, 3);
// Calling add with [2, 3]
// add returned 5

// Class decorator
function sealed(
  target: Function,
  context: ClassDecoratorContext
) {
  Object.seal(target);
  Object.seal(target.prototype);
}

@sealed
class ApiService {
  // can't add new properties after sealing
}

// Field decorator with initialization
function minLength(min: number) {
  return function (
    target: undefined,
    context: ClassFieldDecoratorContext
  ) {
    return function (initialValue: string) {
      if (initialValue.length < min) {
        throw new Error(`${String(context.name)} must be at least ${min} characters`);
      }
      return initialValue;
    };
  };
}

class User {
  @minLength(3)
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

The new decorators are more capable and type-safe than the experimental ones. They work with classes, methods, fields, and accessors.

50. What TypeScript patterns are most important for large codebases?

This is an open-ended question that interviewers use to assess your real-world experience. Here are the patterns that matter most at scale.

// 1. Discriminated unions for state management
type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

// 2. Branded types for domain safety
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

// 3. Zod for runtime validation that produces types
import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user", "viewer"])
});

type User = z.infer<typeof UserSchema>;
// Single source of truth for both runtime validation and static type

// 4. Exhaustive switches with never
function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}

// If someone adds a new variant and forgets to handle it, TypeScript errors

// 5. Centralized error types with Result pattern
class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number = 500
  ) {
    super(message);
  }
}

type Result<T, E = AppError> =
  | { ok: true; value: T }
  | { ok: false; error: E };

The real interview answer: "The patterns that matter most at scale are: strict config from the start, discriminated unions for state, branded types for domain safety, runtime validation that produces types (Zod), and exhaustive switch statements. These prevent entire categories of bugs that are expensive to find in production."


How to Use This List

Don't try to memorize 50 answers. Instead, focus on understanding the underlying concepts well enough to explain them conversationally and write working code.

For each topic, ask yourself:

  • Can I explain this without using jargon?
  • Can I write a working example from memory?
  • Do I know when I'd use this - and when I wouldn't?

If you can answer all three, you're ready.

The TypeScript questions that trip people up the most in interviews are about generics, conditional types, and discriminated unions. Spend extra time there. Everything else builds on those foundations.


Practice TypeScript and JavaScript interview questions with real-time feedback at gitGood.dev. Build the skills that actually get you hired.