Introduction

Welcome to Chapter 2 of your comprehensive TypeScript interview preparation guide! This chapter dives deep into four fundamental concepts that are crucial for writing robust, flexible, and type-safe TypeScript applications: Generics, Union Types, Intersection Types, and Type Guards (also known as Type Narrowing). Mastering these concepts is essential for any TypeScript developer, especially those aiming for mid-level to architect roles, as they empower you to create highly reusable components, handle diverse data structures gracefully, and ensure compile-time type safety in complex scenarios.

In modern TypeScript (version 5.x as of January 2026), these features are not just theoretical constructs but practical tools used daily in frameworks like React, Angular, and Node.js applications. Interviewers will assess your understanding not only of what these features are but, more importantly, why and how to apply them effectively in real-world coding challenges and architectural designs. This chapter provides a blend of theoretical questions, practical scenarios, and common pitfalls to help you articulate your knowledge confidently.

Core Interview Questions

1. Generics

Q: What are TypeScript Generics, and why are they fundamental for building reusable and type-safe components? Provide an example.

A: TypeScript Generics provide a way to create reusable components that can work with a variety of types rather than a single one. They allow you to write code that is independent of a specific type while still enforcing type safety. The primary benefit is to capture the type of the arguments in a way that you can use later to describe what the function returns or what a class stores. This ensures that the types are consistent across the component’s usage.

For example, without generics, a function that returns the first element of an array might lose its specific type information:

function getFirstElement(arr: any[]): any {
  return arr[0];
}

const numbers = [1, 2, 3];
const firstNum = getFirstElement(numbers); // type is 'any'

With generics, we can maintain the type information:

function getFirstElement<T>(arr: T[]): T {
  return arr[0];
}

const numbers = [1, 2, 3];
const firstNum = getFirstElement(numbers); // type is 'number'
const strings = ["a", "b", "c"];
const firstStr = getFirstElement(strings); // type is 'string'

Here, T is a type variable that represents the type of the elements in the array. When getFirstElement is called with number[], T becomes number, and the return type is correctly inferred as number.

Key Points:

  • Reusability: Write code once that works with many types.
  • Type Safety: Maintain type information throughout the component’s usage, catching errors at compile-time.
  • Flexibility: Adapt to different data types without sacrificing type checking.
  • Commonly used for: Functions, classes, interfaces, and type aliases.

Common Mistakes:

  • Using any instead of generics, which defeats the purpose of type safety.
  • Not understanding how type parameters are inferred or explicitly specified.
  • Over-constraining generics unnecessarily.

Follow-up:

  • How do you add constraints to a generic type, and when would you use them?
  • Can you explain the difference between a generic function and a generic interface/class?
  • Describe a real-world scenario where generics significantly improve code quality.

Q: Explain generic constraints using the extends keyword. Provide a practical example.

A: Generic constraints allow you to restrict the types that can be used for a generic type parameter. This is achieved using the extends keyword, which specifies that the type argument must either be assignable to the constraint type or have properties that satisfy the constraint. This is crucial when you need to perform operations on the generic type that are only available on a subset of all possible types.

For instance, if you want a generic function that can log the name property of an object, you need to ensure that any type passed to it actually has a name property.

interface Named {
  name: string;
}

function logName<T extends Named>(obj: T): void {
  console.log(obj.name);
}

const user = { name: "Alice", age: 30 };
logName(user); // Works, user has a 'name' property

const product = { name: "Laptop", price: 1200 };
logName(product); // Works

// const invalid = { id: 1 };
// logName(invalid); // Error: Argument of type '{ id: number; }' is not assignable to parameter of type 'Named'.

In this example, T extends Named ensures that any type T passed to logName must at least satisfy the Named interface, guaranteeing that obj.name will always exist and be a string.

Key Points:

  • Type Safety for Operations: Ensures that methods/properties accessed on the generic type actually exist.
  • Specificity: Narrows down the range of acceptable types for the generic parameter.
  • extends keyword: Used to define the constraint.

Common Mistakes:

  • Forgetting to add constraints when performing operations specific to certain types, leading to compile-time errors.
  • Over-constraining, which makes the generic less flexible than intended.
  • Confusing extends in generics with extends for class inheritance.

Follow-up:

  • Can a generic type parameter extend multiple types? How?
  • When would you use keyof any as a generic constraint?
  • Discuss the challenges of debugging type errors in heavily generic code.

2. Union Types

Q: What are Union Types in TypeScript, and how do they differ from any? Provide a scenario where union types are superior.

A: Union Types describe a value that can be one of several types. They are formed using the | (pipe) symbol. For example, string | number means a variable can hold either a string or a number value. Union types are a powerful way to express flexibility while retaining type safety.

The key difference from any is type safety. While any allows a variable to hold any type and disables type checking for that variable, a union type explicitly lists the allowed types. TypeScript’s compiler will still perform type checking on union types, ensuring that operations performed on the variable are valid for all types in the union, or requiring type narrowing before specific operations.

Scenario: Consider a function that accepts either a single ID (number) or an array of IDs (number[]) to fetch data.

type Id = number | number[];

function fetchData(id: Id): void {
  if (typeof id === 'number') {
    console.log(`Fetching data for single ID: ${id}`);
    // id is narrowed to 'number' here, can safely use number methods
  } else {
    console.log(`Fetching data for multiple IDs: ${id.join(', ')}`);
    // id is narrowed to 'number[]' here, can safely use array methods
  }
}

fetchData(123);
fetchData([456, 789]);
// fetchData("abc"); // Error: Argument of type '"abc"' is not assignable to parameter of type 'Id'.

Using any here would allow fetchData("abc") to compile, leading to runtime errors. With Id, TypeScript ensures only number or number[] are passed, and inside the function, it helps you narrow down the type for specific operations.

Key Points:

  • | operator: Defines that a value can be one of several types.
  • Type Safety: Retains type checking, unlike any.
  • Flexibility: Allows variables to hold different but predefined types.
  • Requires Narrowing: Often necessitates type guards to perform type-specific operations.

Common Mistakes:

  • Confusing union types with any, losing type safety.
  • Forgetting to use type guards when operating on a union type, leading to compile-time errors.
  • Creating overly broad union types that make code harder to reason about.

Follow-up:

  • What are “discriminated unions,” and how do they enhance type safety and developer experience?
  • How does TypeScript infer types when working with union types?
  • Can you have a union type of literal types? Give an example.

Q: Explain Discriminated Unions and their benefit in handling complex state or data structures.

A: Discriminated Unions are a powerful pattern in TypeScript that combines union types with a common, literal property (the “discriminant”) that TypeScript can use to narrow down the specific type within the union. This pattern is particularly useful for representing distinct states or variations of an object, enabling exhaustive type checking and safer handling of complex data.

The key benefit is that by checking the value of the discriminant property, TypeScript’s control flow analysis can automatically narrow the type of the variable to a more specific member of the union, allowing you to access properties unique to that specific type without explicit type assertions.

Example: A Result type that can either be a success or an error.

interface SuccessResult {
  status: "success";
  data: any;
}

interface ErrorResult {
  status: "error";
  message: string;
  errorCode: number;
}

type Result = SuccessResult | ErrorResult;

function processResult(res: Result): void {
  if (res.status === "success") {
    console.log("Data:", res.data); // res is now narrowed to SuccessResult
  } else {
    console.error("Error:", res.message, "Code:", res.errorCode); // res is now narrowed to ErrorResult
  }
}

const successfulFetch: Result = { status: "success", data: { user: "John" } };
const failedFetch: Result = { status: "error", message: "Network failed", errorCode: 500 };

processResult(successfulFetch);
processResult(failedFetch);

The status property acts as the discriminant. When res.status === "success", TypeScript knows res must be a SuccessResult and allows access to res.data. If res.status is anything else (in this union, “error”), it knows res must be an ErrorResult and allows access to res.message and res.errorCode.

Key Points:

  • Union of interfaces/types: Each with a common, literal property (the discriminant).
  • Control Flow Analysis: TypeScript uses the discriminant to narrow types.
  • Exhaustive Type Checking: When used with switch statements and never type, it can ensure all cases are handled.
  • Improved Readability and Safety: Makes code dealing with different states much clearer and less error-prone.

Common Mistakes:

  • Using a non-literal type for the discriminant, preventing TypeScript from narrowing.
  • Not handling all possible cases in a switch or if/else if block, potentially leading to runtime errors if not caught by exhaustive checks.
  • Forgetting to define a common discriminant property across all union members.

Follow-up:

  • How can the never type be used with discriminated unions to ensure exhaustive checking?
  • Can you use properties other than strings as discriminants?
  • Discuss how discriminated unions are used in popular state management libraries (e.g., Redux actions).

3. Intersection Types

Q: What are TypeScript Intersection Types, and how do they differ from interface extension (extends)? When would you prefer one over the other?

A: Intersection Types allow you to combine multiple types into a single type, creating a new type that has all the properties of the combined types. They are formed using the & (ampersand) symbol. If you have two types, A and B, A & B represents a type that has both the properties of A and the properties of B.

The key difference from interface extension (extends) lies in their primary use cases and how they combine types:

  • Interface Extension (extends): Used to build a new interface on top of existing ones. It creates a hierarchical relationship, where the extending interface inherits members from the base interface(s). This is ideal for defining a “is-a” relationship (e.g., interface Dog extends Animal). extends is typically used for interfaces and classes.

    interface Animal {
      name: string;
    }
    interface Dog extends Animal { // Dog is an Animal with additional properties
      bark(): void;
    }
    const myDog: Dog = { name: "Buddy", bark: () => console.log("Woof!") };
    
  • Intersection Types (&): Used to compose new types by combining existing ones, without necessarily implying an inheritance hierarchy. It creates a “has-a” or “combines-features-of” relationship. This is highly flexible and can combine interfaces, type aliases, and even primitive types (though combining primitives usually results in never if they are distinct).

    interface HasID {
      id: string;
    }
    interface HasName {
      name: string;
    }
    type UserProfile = HasID & HasName & { email: string }; // UserProfile has an ID, a Name, and an Email
    const user: UserProfile = { id: "123", name: "Alice", email: "alice@example.com" };
    

When to prefer one over the other:

  • Prefer extends for:
    • Hierarchical relationships: When you want to model an “is-a” relationship (e.g., Square extends Shape).
    • Class inheritance: extends is the standard for classes.
    • Clear base/derived structure: When you want to explicitly show one type is a more specific version of another.
  • Prefer & for:
    • Composition: When you want to combine features from multiple, often unrelated, types into a single new type (e.g., Draggable & Resizable).
    • Ad-hoc type creation: When you need a temporary or specific combination of properties without defining a new interface hierarchy.
    • Combining type aliases: extends cannot be used with type aliases directly in the same way as interfaces.
    • Adding properties to existing types without modifying them: Create a new type that has properties of an existing type plus some new ones.

Key Points:

  • & operator: Combines properties from multiple types.
  • extends keyword: Creates an inheritance hierarchy for interfaces/classes.
  • “Is-a” vs. “Has-a” / “Composed-of”: The core conceptual difference.
  • Flexibility: Intersection types are often more flexible for ad-hoc type composition.

Common Mistakes:

  • Confusing & with | (union types). & means all properties, | means one of the types.
  • Using intersection types when extends would better represent the conceptual relationship.
  • Attempting to intersect types with conflicting properties (e.g., { a: string } & { a: number } results in { a: never }).

Follow-up:

  • What happens when you intersect two interfaces that have the same property but with different types (e.g., type Conflicting = { a: string } & { a: number })?
  • Can intersection types be used with generic types? Provide an example.
  • Discuss a real-world scenario where composing types with & is more beneficial than extends.

4. Type Guards (Type Narrowing)

Q: What is Type Narrowing in TypeScript, and why is it essential for working with union types? Describe different types of built-in type guards.

A: Type Narrowing (often facilitated by Type Guards) is the process by which TypeScript’s compiler refines the type of a variable within a specific code block, based on runtime checks. When you have a variable with a union type (e.g., string | number), TypeScript initially only knows it could be either. Type narrowing allows you to perform checks that tell TypeScript, “within this scope, this variable is definitely a string,” or “here, it’s definitely a number.” This enables you to safely access properties or call methods specific to that narrowed type without type assertions.

It’s essential for union types because without it, TypeScript would only allow operations that are valid for all members of the union, which is often too restrictive. Type narrowing allows you to leverage the full capabilities of each type within the union.

Different types of built-in type guards:

  1. typeof Type Guards: Checks the JavaScript runtime type of a variable.

    • typeof x === "string"
    • typeof x === "number"
    • typeof x === "boolean"
    • typeof x === "symbol"
    • typeof x === "bigint"
    • typeof x === "function"
    • typeof x === "object" (Note: null is also “object”)
    • typeof x === "undefined"
    function printId(id: string | number) {
      if (typeof id === "string") {
        console.log(id.toUpperCase()); // id is narrowed to string
      } else {
        console.log(id.toFixed(2)); // id is narrowed to number
      }
    }
    
  2. instanceof Type Guards: Checks if a value is an instance of a particular class.

    class Dog {
      bark() { console.log("Woof!"); }
    }
    class Cat {
      meow() { console.log("Meow!"); }
    }
    type Pet = Dog | Cat;
    
    function makeSound(pet: Pet) {
      if (pet instanceof Dog) {
        pet.bark(); // pet is narrowed to Dog
      } else {
        pet.meow(); // pet is narrowed to Cat
      }
    }
    
  3. in Operator Type Guards: Checks if an object has a specific property.

    interface Admin {
      name: string;
      privileges: string[];
    }
    interface User {
      name: string;
      startDate: Date;
    }
    type Person = Admin | User;
    
    function greet(p: Person) {
      if ("privileges" in p) {
        console.log(`Hello Admin ${p.name}, your privileges are: ${p.privileges.join(', ')}`); // p is narrowed to Admin
      } else {
        console.log(`Hello User ${p.name}, joined on: ${p.startDate.toLocaleDateString()}`); // p is narrowed to User
      }
    }
    
  4. Equality Narrowing: Using ===, !==, ==, != to compare against literal values, null, or undefined.

    function processValue(value: string | null) {
      if (value !== null) {
        console.log(value.length); // value is narrowed to string
      } else {
        console.log("Value is null");
      }
    }
    
  5. Truthiness Narrowing: Checking if a value is truthy or falsy (e.g., if (value)). This can narrow string | undefined to string, or string | null to string.

    function printText(text: string | undefined) {
      if (text) { // text is truthy, so it must be a string
        console.log(text.length);
      } else {
        console.log("No text provided.");
      }
    }
    

Key Points:

  • Runtime Checks: Type guards are regular JavaScript runtime checks.
  • Compile-Time Narrowing: TypeScript uses the results of these checks to refine types.
  • Safety: Prevents accessing properties/methods that might not exist on a given type.
  • Readability: Makes code clearer by explicitly handling different types.

Common Mistakes:

  • Relying on type assertions (as Type) instead of proper type guards, bypassing type safety.
  • Incorrectly assuming typeof null is "null" (it’s "object").
  • Not handling all possible union cases, leading to potential runtime errors in unhandled branches.

Follow-up:

  • How do user-defined type guards (is keyword) work, and when would you create one?
  • Can you combine multiple type guards?
  • Discuss the challenges of narrowing types in asynchronous code or callbacks.

Q: Design and implement a user-defined type guard for a complex object structure.

A: User-defined type guards are functions that return a boolean and have a special return type signature: parameterName is Type. When TypeScript sees such a function return true, it knows that the parameterName within that scope now has the Type. This is invaluable when built-in type guards aren’t sufficient, particularly for checking the shape of objects or specific properties.

Scenario: We have a union type Notification which can be either a TextNotification or an ImageNotification. We want a type guard to check if a notification is an ImageNotification.

interface TextNotification {
  type: "text";
  message: string;
  senderId: string;
}

interface ImageNotification {
  type: "image";
  imageUrl: string;
  altText?: string;
  senderId: string;
}

type Notification = TextNotification | ImageNotification;

// User-defined type guard
function isImageNotification(notification: Notification): notification is ImageNotification {
  return notification.type === "image" && typeof (notification as ImageNotification).imageUrl === 'string';
}

function processNotification(notification: Notification): void {
  if (isImageNotification(notification)) {
    // TypeScript now knows 'notification' is an ImageNotification
    console.log(`Processing image from: ${notification.senderId}, URL: ${notification.imageUrl}`);
    if (notification.altText) {
        console.log(`Alt text: ${notification.altText}`);
    }
  } else {
    // TypeScript knows 'notification' is a TextNotification
    console.log(`Processing text from: ${notification.senderId}, Message: "${notification.message}"`);
  }
}

const textNotif: Notification = { type: "text", message: "Hello there!", senderId: "user1" };
const imageNotif: Notification = { type: "image", imageUrl: "https://example.com/pic.jpg", senderId: "user2" };
const imageNotifWithAlt: Notification = { type: "image", imageUrl: "https://example.com/pic2.jpg", altText: "A nice view", senderId: "user3" };

processNotification(textNotif);
processNotification(imageNotif);
processNotification(imageNotifWithAlt);

In isImageNotification, the check notification.type === "image" serves as a discriminant, and typeof (notification as ImageNotification).imageUrl === 'string' adds robustness by ensuring the imageUrl property, specific to ImageNotification, actually exists and is a string. The notification is ImageNotification return type signature is critical for TypeScript’s type narrowing capabilities.

Key Points:

  • parameterName is Type: The special return type signature that enables narrowing.
  • Runtime Logic: The function body contains standard JavaScript logic to determine the type.
  • Flexibility: Allows defining custom checks for complex types or specific business rules.
  • Commonly used for: Discriminated unions, checking for specific object shapes, or validating external data.

Common Mistakes:

  • Forgetting the is keyword in the return type, making it a regular boolean function that doesn’t narrow types.
  • Making the runtime check too simplistic or inaccurate, leading to false positives or negatives and potential runtime errors despite compile-time safety.
  • Over-relying on type assertions within the guard itself without proper checks.

Follow-up:

  • Can a user-defined type guard be generic? Provide an example.
  • How would you handle a scenario where a type guard needs to check for the absence of a property?
  • Discuss the performance implications of complex type guards in hot code paths.

5. Advanced Scenarios

Q: You are designing an API client that fetches data of various types. How would you use generics to create a flexible yet type-safe fetchData function, and what generic constraints would you apply?

A: To design a flexible yet type-safe fetchData function for an API client using generics, we’d define a generic type parameter for the expected response data. We’d also ensure that the API response structure can be consistently handled, for instance, by expecting a data property.

// Define a common interface for API responses that encapsulate the actual data
interface ApiResponse<T> {
  status: "success" | "error";
  data?: T;
  message?: string;
  statusCode?: number;
}

// Our generic fetchData function
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    const json: ApiResponse<T> = await response.json();

    // Basic runtime check for the expected structure (optional but good practice)
    if (json.status === "success" && json.data === undefined) {
        console.warn(`API response for ${url} was successful but 'data' property is missing.`);
    }

    return json;
  } catch (error: any) {
    console.error(`Error fetching from ${url}:`, error.message);
    return {
      status: "error",
      message: error.message || "An unknown error occurred",
      statusCode: (error.response && error.response.status) || 500,
    };
  }
}

// Example usage:
interface User {
  id: number;
  name: string;
  email: string;
}

interface Product {
  productId: string;
  name: string;
  price: number;
}

async function getUser(id: number): Promise<ApiResponse<User>> {
  return fetchData<User>(`/api/users/${id}`);
}

async function getProducts(): Promise<ApiResponse<Product[]>> {
  return fetchData<Product[]>("/api/products");
}

// Simulate API calls
(async () => {
  const userResponse = await getUser(1);
  if (userResponse.status === "success" && userResponse.data) {
    console.log("Fetched user:", userResponse.data.name); // userResponse.data is of type User
  } else {
    console.error("Failed to fetch user:", userResponse.message);
  }

  const productsResponse = await getProducts();
  if (productsResponse.status === "success" && productsResponse.data) {
    console.log("Fetched products:", productsResponse.data.length); // productsResponse.data is of type Product[]
  } else {
    console.error("Failed to fetch products:", productsResponse.message);
  }
})();

Generic Constraints: In this specific fetchData example, the primary generic T doesn’t strictly need an extends constraint because T is merely specifying the shape of the data property within the ApiResponse. However, if we wanted to perform specific operations on T within fetchData (e.g., if T was always expected to have an id property for logging), we would add a constraint:

// Example with a constraint if T needed an 'id' property
interface HasId {
    id: string | number;
}
async function fetchDataWithIdLogging<T extends HasId>(url: string): Promise<ApiResponse<T>> {
    const response = await fetchData<T>(url);
    if (response.status === "success" && response.data) {
        console.log(`Fetched item with ID: ${response.data.id}`); // Safe due to T extends HasId
    }
    return response;
}

Key Points:

  • Type Parameter T: Represents the expected data shape.
  • Promise<ApiResponse<T>>: Ensures the asynchronous return type is also generic and type-safe.
  • ApiResponse<T> Interface: A wrapper to consistently handle API responses (success/error, data, message).
  • Minimal Constraints: Only add extends constraints if operations within the generic function itself require specific properties or methods on T.

Common Mistakes:

  • Returning any from fetchData instead of Promise<ApiResponse<T>>, losing all type safety.
  • Not handling error cases in a type-safe manner (e.g., returning {} which might not match T).
  • Over-complicating generic constraints when simple type inference would suffice.

Follow-up:

  • How would you handle cases where the API might return different error structures using union types?
  • Imagine the API has pagination. How would you adjust the generic ApiResponse and fetchData to support pagination metadata?
  • How would you ensure that the json object returned from response.json() actually conforms to ApiResponse<T> at runtime (beyond TypeScript’s compile-time checks)?

6. Tricky Type Puzzles / Scenario-Based

Q: Consider a scenario where you have a configuration object that can have different properties based on a mode field. How would you model this using TypeScript to ensure type safety and provide good autocompletion for each mode?

A: This is a classic use case for Discriminated Unions. We can define distinct interfaces for each mode and then create a union type from them, using the mode property as the discriminant.

// Define interfaces for each configuration mode
interface DevelopmentConfig {
  mode: "development";
  port: number;
  debugLogging: boolean;
  apiEndpoint: string;
}

interface ProductionConfig {
  mode: "production";
  port: number;
  cacheEnabled: boolean;
  cdnUrl: string;
}

interface TestConfig {
  mode: "test";
  port: number;
  mockDataPath: string;
}

// Create a union type using 'mode' as the discriminant
type AppConfig = DevelopmentConfig | ProductionConfig | TestConfig;

// Function to process configuration based on its mode
function initializeApp(config: AppConfig): void {
  switch (config.mode) {
    case "development":
      // config is narrowed to DevelopmentConfig
      console.log(`Dev Mode: Port ${config.port}, Debug: ${config.debugLogging}, API: ${config.apiEndpoint}`);
      // config.cacheEnabled; // Error: Property 'cacheEnabled' does not exist on type 'DevelopmentConfig'.
      break;
    case "production":
      // config is narrowed to ProductionConfig
      console.log(`Prod Mode: Port ${config.port}, Cache: ${config.cacheEnabled}, CDN: ${config.cdnUrl}`);
      break;
    case "test":
      // config is narrowed to TestConfig
      console.log(`Test Mode: Port ${config.port}, Mock Data: ${config.mockDataPath}`);
      break;
    default:
      // This 'default' case should ideally be unreachable if all modes are covered.
      // We can use the 'never' type here for exhaustive checking.
      const exhaustiveCheck: never = config;
      throw new Error(`Unhandled config mode: ${exhaustiveCheck}`);
  }
}

// Example usage:
const devConfig: AppConfig = {
  mode: "development",
  port: 3000,
  debugLogging: true,
  apiEndpoint: "http://localhost:8080/api",
};

const prodConfig: AppConfig = {
  mode: "production",
  port: 80,
  cacheEnabled: true,
  cdnUrl: "https://cdn.example.com",
};

initializeApp(devConfig);
initializeApp(prodConfig);

// Example of exhaustive checking:
// If a new mode was added to AppConfig but not handled in initializeApp,
// the 'never' type in the default case would cause a compile-time error,
// forcing the developer to update the switch statement.

Benefits:

  • Type Safety: Ensures that when config.mode is "development", only DevelopmentConfig properties are accessible.
  • Autocompletion: IDEs provide excellent autocompletion based on the narrowed type within each case block.
  • Exhaustive Checking: Using the never type in the default case of a switch statement ensures that all possible modes are handled, catching omissions at compile time.
  • Readability: Clearly defines the structure for each configuration variant.

Key Points:

  • Discriminating property: A common literal string property (mode in this case).
  • Union of interfaces: Each interface representing a distinct state or variant.
  • switch statement or if/else if: Used to narrow the type based on the discriminant.
  • never type: For exhaustive checks in switch statements.

Common Mistakes:

  • Not making the mode property a literal type (e.g., mode: string instead of mode: "development"), which prevents TypeScript from discriminating.
  • Forgetting to handle all cases in the switch statement, especially without never for exhaustive checking.
  • Trying to access properties specific to one mode when the type hasn’t been narrowed, leading to compile-time errors.

Follow-up:

  • How would you handle a scenario where some properties are common across all modes, while others are specific?
  • Can this pattern be applied to function overloads or generic functions?
  • Discuss how this approach scales when you have a very large number of modes or complex nested configurations.

7. Real-world Refactoring Scenarios

Q: You’re refactoring an old JavaScript utility function mergeObjects(obj1, obj2) that deeply merges two objects. How would you convert this to TypeScript using generics to ensure the return type accurately reflects the merged object’s properties?

A: The challenge with merging objects in TypeScript is accurately inferring the return type, which should contain all properties from both input objects. This is a perfect use case for intersection types combined with generics.

/**
 * Deeply merges two objects, returning a new object with properties from both.
 * If properties exist in both, obj2's value will overwrite obj1's.
 *
 * @template T1 The type of the first object.
 * @template T2 The type of the second object.
 * @param {T1} obj1 The first object to merge.
 * @param {T2} obj2 The second object to merge, whose properties take precedence.
 * @returns {T1 & T2} A new object representing the deep merge of obj1 and obj2.
 */
function deepMerge<T1 extends object, T2 extends object>(obj1: T1, obj2: T2): T1 & T2 {
  const result: any = {}; // Start with 'any' for intermediate mutable operations, then cast or build up type.

  // Helper for recursive merge
  const merge = (target: any, source: any) => {
    for (const key in source) {
      if (Object.prototype.hasOwnProperty.call(source, key)) {
        if (typeof source[key] === 'object' && source[key] !== null &&
            !Array.isArray(source[key]) &&
            typeof target[key] === 'object' && target[key] !== null &&
            !Array.isArray(target[key])) {
          // Both are objects, recurse
          target[key] = merge(target[key] || {}, source[key]);
        } else {
          // Otherwise, assign or overwrite
          target[key] = source[key];
        }
      }
    }
    return target;
  };

  // Start with a deep copy of obj1
  merge(result, JSON.parse(JSON.stringify(obj1))); // Simple deep copy, might not handle functions/dates
  // Then merge obj2 into the result
  merge(result, obj2);

  return result as T1 & T2; // Assert the final type
}

// Example usage:
interface UserProfile {
  id: string;
  name: string;
  settings: {
    theme: "dark" | "light";
    notifications: boolean;
  };
}

interface UserPreferences {
  settings: {
    theme: "light";
    language: string;
  };
  isActive: boolean;
}

const defaultProfile: UserProfile = {
  id: "abc",
  name: "Guest",
  settings: {
    theme: "dark",
    notifications: true,
  },
};

const userOverrides: UserPreferences = {
  settings: {
    theme: "light",
    language: "en-US",
  },
  isActive: true,
};

const mergedUser = deepMerge(defaultProfile, userOverrides);

// TypeScript correctly infers mergedUser as UserProfile & UserPreferences
// This means it has: id, name, settings (with theme, notifications, language), isActive
console.log(mergedUser.id);              // "abc"
console.log(mergedUser.name);            // "Guest"
console.log(mergedUser.settings.theme);  // "light" (overwritten by userOverrides)
console.log(mergedUser.settings.notifications); // true (from defaultProfile)
console.log(mergedUser.settings.language); // "en-US" (from userOverrides)
console.log(mergedUser.isActive);        // true

// mergedUser.someNonExistentProp; // Error: Property 'someNonExistentProp' does not exist

Explanation:

  1. Generic Parameters T1 and T2: These capture the exact types of the two input objects.
  2. Generic Constraints T1 extends object, T2 extends object: These ensure that the inputs are indeed objects.
  3. Return Type T1 & T2: This is the crucial part. An intersection type T1 & T2 tells TypeScript that the returned object will have all properties from T1 and all properties from T2. If a property exists in both, TypeScript’s structural typing correctly resolves the final type based on the last assignment (effectively T2’s type if T2 overwrites T1).
  4. Runtime Implementation: The deepMerge function itself performs the actual merging logic. The JSON.parse(JSON.stringify(obj1)) is a simple (but limited) way to deep copy. For production, a more robust deep clone utility is recommended.
  5. Type Assertion as T1 & T2: Since the result variable starts as any (due to the dynamic nature of building an object in JavaScript), we need to assert its final type to T1 & T2 before returning, informing TypeScript of its correct shape.

Key Points:

  • Generics for input types: Capture the specific types of the objects being merged.
  • Intersection type for return: T1 & T2 accurately represents the combined properties.
  • Runtime logic: The actual merging is a JavaScript implementation detail, but TypeScript ensures its type safety.
  • Type Assertion: Necessary to reconcile the dynamically built object with its intended static type.

Common Mistakes:

  • Returning object or any instead of T1 & T2, losing type information.
  • Not handling deep merging for nested objects, leading to incorrect runtime behavior or types that don’t reflect the actual merge.
  • Forgetting the extends object constraint, which might allow non-object types if not handled carefully.

Follow-up:

  • How would you modify deepMerge to handle arrays within objects, ensuring they are also merged or concatenated appropriately?
  • What are the limitations of JSON.parse(JSON.stringify(obj)) for deep cloning, and what alternatives exist in TypeScript for robust deep cloning?
  • How would you add a third generic parameter Options to allow configuration of the merge behavior (e.g., overwrite arrays, concatenate arrays)?

MCQ Section

Question 1:

Which of the following best describes the purpose of TypeScript Generics? A. To allow a variable to hold any type, effectively disabling type checking. B. To create a type that can be one of several predefined types. C. To enable components to work with a variety of data types while maintaining type safety. D. To combine properties from multiple existing types into a new single type.

Correct Answer: C Explanation:

  • A describes any.
  • B describes Union Types.
  • C accurately describes Generics, allowing code reusability and type safety across different types.
  • D describes Intersection Types.

Question 2:

Given the following TypeScript code:

interface HasId {
  id: string;
}
interface HasName {
  name: string;
}
type UserInfo = HasId & HasName;

What type does UserInfo represent? A. A type that has either an id property or a name property. B. A type that has both an id property and a name property. C. A type that extends HasId and optionally has a name property. D. A type that can only be HasId or HasName, but not both.

Correct Answer: B Explanation:

  • The & operator creates an intersection type, meaning the new type must possess all properties from the intersected types. So, UserInfo must have both id and name.
  • A and D describe union types (|).
  • C describes interface extension or optional properties.

Question 3:

Which of the following is NOT a built-in type guard mechanism in TypeScript? A. typeof B. instanceof C. is D. in

Correct Answer: C Explanation:

  • typeof, instanceof, and in are built-in JavaScript operators that TypeScript leverages for type narrowing.
  • is is a keyword used in the return type signature of a user-defined type guard function (e.g., value is MyType), not a built-in operator itself.

Question 4:

Consider the following:

type Status = "pending" | "success" | "error";

function handleStatus(status: Status) {
  if (status === "success") {
    // What is the type of 'status' here?
  } else if (status === "error") {
    // What is the type of 'status' here?
  }
  // What is the type of 'status' here?
}

What is the type of status in the final else block (after else if (status === "error"))? A. "pending" B. "success" | "error" C. Status D. never

Correct Answer: A Explanation:

  • TypeScript’s control flow analysis narrows the type. If status is not "success" and not "error", the only remaining possibility from the Status union is "pending".

Question 5:

You have a generic function processItem<T>(item: T) that needs to ensure T always has a value property of type number. How would you define the generic constraint? A. function processItem<T extends { value: number }>(item: T) B. function processItem<T>(item: T & { value: number }) C. function processItem<T: { value: number }>(item: T) D. function processItem<T implements { value: number }>(item: T)

Correct Answer: A Explanation:

  • A uses extends to correctly apply a constraint, ensuring T must be assignable to an object with a value: number property.
  • B uses an intersection type, which means the input item must also have { value: number }, but doesn’t constrain T itself.
  • C and D use incorrect syntax for generic constraints.

Mock Interview Scenario: Building a Flexible Event Dispatcher

Scenario Setup: You are tasked with designing a flexible event dispatching system for a large-scale application. This system needs to handle various types of events, each with potentially different payloads. The goal is to ensure type safety when dispatching and subscribing to events, providing a robust and maintainable solution. The application uses TypeScript 5.x.

Interviewer: “Alright, let’s design an event dispatcher. We need a way for different parts of our application to emit events and for other parts to listen to them. Critically, we want strong type safety. How would you approach defining the event types and the dispatcher’s methods?”

Expected Flow of Conversation:

  1. Defining Event Types (Unions & Discriminated Unions):

    • Candidate: “First, we need to define our event types. Since events will have different payloads, a union type is ideal. To maintain type safety and allow TypeScript to narrow event types, I’d use a discriminated union, with an eventType literal string property as the discriminant.”
    • Interviewer might ask: “Can you show me an example event type definition?”
    • Candidate:
      interface UserLoggedInEvent {
        eventType: "USER_LOGGED_IN";
        payload: { userId: string; timestamp: number };
      }
      
      interface ProductAddedToCartEvent {
        eventType: "PRODUCT_ADDED_TO_CART";
        payload: { productId: string; quantity: number };
      }
      
      interface ErrorOccurredEvent {
        eventType: "ERROR_OCCURRED";
        payload: { message: string; code: number; stack?: string };
      }
      
      type AppEvent = UserLoggedInEvent | ProductAddedToCartEvent | ErrorOccurredEvent;
      
  2. Designing the Dispatcher (Generics):

    • Candidate: “Next, we need the EventDispatcher class. Its dispatch method should accept any AppEvent. The subscribe method is where generics become crucial. We want to subscribe to specific event types, and the callback should receive the correct payload type for that event.”
    • Interviewer might ask: “How would you define the subscribe method to achieve this type safety?”
    • Candidate:
      type EventCallback<T extends AppEvent> = (event: T) => void;
      
      class EventDispatcher {
        private listeners: Map<AppEvent['eventType'], Set<EventCallback<any>>> = new Map();
      
        dispatch(event: AppEvent): void {
          const callbacks = this.listeners.get(event.eventType);
          if (callbacks) {
            callbacks.forEach(callback => callback(event));
          }
        }
      
        // Generic subscribe method
        subscribe<T extends AppEvent>(
          eventType: T['eventType'],
          callback: EventCallback<T>
        ): () => void { // Return unsubscribe function
          if (!this.listeners.has(eventType)) {
            this.listeners.set(eventType, new Set());
          }
          this.listeners.get(eventType)?.add(callback as EventCallback<any>);
      
          return () => this.unsubscribe(eventType, callback as EventCallback<any>);
        }
      
        private unsubscribe<T extends AppEvent>(
          eventType: T['eventType'],
          callback: EventCallback<any>
        ): void {
          this.listeners.get(eventType)?.delete(callback);
          if (this.listeners.get(eventType)?.size === 0) {
            this.listeners.delete(eventType);
          }
        }
      }
      
    • Interviewer might probe: “Why T extends AppEvent? And what’s with the EventCallback<any> cast?”
    • Candidate: " T extends AppEvent ensures that the generic type T is always one of our defined application events. This allows us to use T['eventType'] to correctly infer the literal string type for the eventType parameter. The EventCallback<any> cast is a necessary evil here due to the way Map stores callbacks. While we know the Set for a given eventType will only contain callbacks for that specific event, TypeScript’s type system struggles to prove this directly at the Map’s definition level without complex conditional types or type assertions that might overcomplicate the Map’s type itself. The subscribe method’s callback parameter, however, is strongly typed to EventCallback<T>, so users of the dispatcher get full type safety there."
  3. Demonstrating Usage & Type Safety (Type Narrowing):

    • Interviewer: “Show me how a component would subscribe and what type safety benefits it gets.”
    • Candidate:
      const dispatcher = new EventDispatcher();
      
      // Subscribing to a specific event
      const unsubscribeUser = dispatcher.subscribe("USER_LOGGED_IN", (event) => {
        // 'event' is automatically narrowed to UserLoggedInEvent
        console.log(`User ${event.payload.userId} logged in at ${new Date(event.payload.timestamp).toLocaleString()}`);
        // event.payload.productId; // Error: Property 'productId' does not exist on type '{ userId: string; timestamp: number; }'.
      });
      
      const unsubscribeError = dispatcher.subscribe("ERROR_OCCURRED", (event) => {
        // 'event' is automatically narrowed to ErrorOccurredEvent
        console.error(`Error ${event.payload.code}: ${event.payload.message}`);
      });
      
      // Dispatching events
      dispatcher.dispatch({
        eventType: "USER_LOGGED_IN",
        payload: { userId: "user-456", timestamp: Date.now() },
      });
      
      dispatcher.dispatch({
        eventType: "ERROR_OCCURRED",
        payload: { message: "Database connection failed", code: 1001 },
      });
      
      // unsubscribeUser(); // Later, to stop listening
      
    • Candidate: “As you can see, when subscribing to USER_LOGGED_IN, the event parameter in the callback is correctly typed as UserLoggedInEvent, giving us autocompletion and compile-time errors if we try to access properties not on that specific event type. This is thanks to the discriminated union AppEvent and the generic T in subscribe.”

Red Flags to Avoid:

  • Using any excessively: Especially for event payloads or the EventCallback type.
  • Lack of discriminated unions: If events are just type Event = { type: string; payload: any }, you lose most type safety benefits.
  • Poor generic usage: Not using T extends AppEvent correctly, or not leveraging T['eventType'] for parameter types.
  • No unsubscribe mechanism: A practical dispatcher needs to allow listeners to be removed.
  • Ignoring runtime checks: While TypeScript provides compile-time safety, real-world data (e.g., from network) might not conform. Mentioning the need for runtime validation (e.g., Zod, io-ts) for incoming events would be a strong plus for an architect role.

Practical Tips

  1. Understand the “Why”: Don’t just memorize syntax. For generics, understand why they solve reusability problems. For unions, understand why they’re better than any for flexible types. For type guards, understand why runtime checks are needed for compile-time safety.
  2. Practice with Real-World Scenarios: The best way to learn is by applying these concepts. Try building:
    • A generic useState hook for React.
    • A generic fetch wrapper.
    • A configuration loader with discriminated unions.
    • A validation utility using custom type guards.
  3. Read the TypeScript Handbook: The official documentation is excellent and always up-to-date. Pay special attention to the “Generics,” “Union and Intersection Types,” and “Type Narrowing” sections.
  4. Explore Utility Types: Many built-in utility types (like Partial, Readonly, Exclude, Extract, Omit, Pick) are built using generics and conditional types. Understanding them will deepen your comprehension.
  5. Study Open-Source Code: Look at how popular libraries (e.g., React Query, Zustand, Redux Toolkit) use these advanced type features to provide their powerful APIs.
  6. Experiment with the TypeScript Playground: It’s an invaluable tool for testing type definitions and seeing how the compiler behaves.
  7. Identify Patterns:
    • T extends ...: Always think “I need to perform an operation specific to a subset of types.”
    • A | B: Think “This value can be one of these types; I’ll need to narrow it down.”
    • A & B: Think “This value has all properties of A AND all properties of B.”
    • if (typeof x === 'string') / if ('prop' in obj) / if (isMyType(obj)): Always think “I need to tell TypeScript which specific type within a union this is.”

Summary

This chapter has equipped you with a deep understanding of TypeScript’s Generics, Union Types, Intersection Types, and Type Guards. These concepts are foundational for writing highly flexible, reusable, and most importantly, type-safe code in any modern TypeScript application.

  • Generics empower you to create components that adapt to various types without sacrificing type safety.
  • Union Types allow variables to hold values of several distinct types, enhancing flexibility.
  • Intersection Types enable powerful composition, combining features from multiple types into a single, comprehensive type.
  • Type Guards and Type Narrowing are critical for safely working with union types, guiding TypeScript’s compiler to understand the specific type of a variable at runtime.

Mastering these areas will not only improve your daily coding but also position you strongly in interviews, demonstrating your ability to design robust and maintainable software architectures. Continue practicing with diverse scenarios, and always strive to understand the underlying principles behind each TypeScript feature.


References

  1. TypeScript Handbook - Generics: https://www.typescriptlang.org/docs/handbook/2/generics.html
  2. TypeScript Handbook - Union and Intersection Types: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types
  3. TypeScript Handbook - Type Narrowing: https://www.typescriptlang.org/docs/handbook/2/narrowing.html
  4. TypeScript Deep Dive - Generics: https://basarat.gitbook.io/typescript/type-system/generics
  5. GeeksforGeeks - TypeScript Conditional and Mapped Types: (While this chapter focuses on different topics, GeeksforGeeks is a good general resource for TypeScript concepts) https://www.geeksforgeeks.org/typescript/typescript-conditional-and-mapped-types/

This interview preparation guide is AI-assisted and reviewed. It references official documentation and recognized interview preparation resources.