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
anyinstead 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.
extendskeyword: 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
extendsin generics withextendsfor class inheritance.
Follow-up:
- Can a generic type parameter extend multiple types? How?
- When would you use
keyof anyas 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
switchstatements andnevertype, 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
switchorif/else ifblock, 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
nevertype 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).extendsis 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 inneverif 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: "[email protected]" };
When to prefer one over the other:
- Prefer
extendsfor:- Hierarchical relationships: When you want to model an “is-a” relationship (e.g.,
Square extends Shape). - Class inheritance:
extendsis the standard for classes. - Clear base/derived structure: When you want to explicitly show one type is a more specific version of another.
- Hierarchical relationships: When you want to model an “is-a” relationship (e.g.,
- 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:
extendscannot 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.
- Composition: When you want to combine features from multiple, often unrelated, types into a single new type (e.g.,
Key Points:
&operator: Combines properties from multiple types.extendskeyword: 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
extendswould 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 thanextends.
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:
typeofType 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:nullis 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 } }instanceofType 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 } }inOperator 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 } }Equality Narrowing: Using
===,!==,==,!=to compare against literal values,null, orundefined.function processValue(value: string | null) { if (value !== null) { console.log(value.length); // value is narrowed to string } else { console.log("Value is null"); } }Truthiness Narrowing: Checking if a value is truthy or falsy (e.g.,
if (value)). This can narrowstring | undefinedtostring, orstring | nulltostring.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 nullis"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 (
iskeyword) 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
iskeyword 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
extendsconstraints if operations within the generic function itself require specific properties or methods onT.
Common Mistakes:
- Returning
anyfromfetchDatainstead ofPromise<ApiResponse<T>>, losing all type safety. - Not handling error cases in a type-safe manner (e.g., returning
{}which might not matchT). - 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
ApiResponseandfetchDatato support pagination metadata? - How would you ensure that the
jsonobject returned fromresponse.json()actually conforms toApiResponse<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.modeis"development", onlyDevelopmentConfigproperties are accessible. - Autocompletion: IDEs provide excellent autocompletion based on the narrowed type within each
caseblock. - Exhaustive Checking: Using the
nevertype in thedefaultcase of aswitchstatement 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 (
modein this case). - Union of interfaces: Each interface representing a distinct state or variant.
switchstatement orif/else if: Used to narrow the type based on the discriminant.nevertype: For exhaustive checks inswitchstatements.
Common Mistakes:
- Not making the
modeproperty a literal type (e.g.,mode: stringinstead ofmode: "development"), which prevents TypeScript from discriminating. - Forgetting to handle all cases in the
switchstatement, especially withoutneverfor 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:
- Generic Parameters
T1andT2: These capture the exact types of the two input objects. - Generic Constraints
T1 extends object,T2 extends object: These ensure that the inputs are indeed objects. - Return Type
T1 & T2: This is the crucial part. An intersection typeT1 & T2tells TypeScript that the returned object will have all properties fromT1and all properties fromT2. If a property exists in both, TypeScript’s structural typing correctly resolves the final type based on the last assignment (effectivelyT2’s type ifT2overwritesT1). - Runtime Implementation: The
deepMergefunction itself performs the actual merging logic. TheJSON.parse(JSON.stringify(obj1))is a simple (but limited) way to deep copy. For production, a more robust deep clone utility is recommended. - Type Assertion
as T1 & T2: Since theresultvariable starts asany(due to the dynamic nature of building an object in JavaScript), we need to assert its final type toT1 & T2before 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 & T2accurately 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
objectoranyinstead ofT1 & 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 objectconstraint, which might allow non-object types if not handled carefully.
Follow-up:
- How would you modify
deepMergeto 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
Optionsto 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,UserInfomust have bothidandname. - 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, andinare built-in JavaScript operators that TypeScript leverages for type narrowing.isis 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
statusis not"success"and not"error", the only remaining possibility from theStatusunion 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
extendsto correctly apply a constraint, ensuringTmust be assignable to an object with avalue: numberproperty. - B uses an intersection type, which means the input
itemmust also have{ value: number }, but doesn’t constrainTitself. - 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:
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
eventTypeliteral 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;
- 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
Designing the Dispatcher (Generics):
- Candidate: “Next, we need the
EventDispatcherclass. Itsdispatchmethod should accept anyAppEvent. Thesubscribemethod 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
subscribemethod 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 theEventCallback<any>cast?” - Candidate: "
T extends AppEventensures that the generic typeTis always one of our defined application events. This allows us to useT['eventType']to correctly infer the literal string type for theeventTypeparameter. TheEventCallback<any>cast is a necessary evil here due to the wayMapstores callbacks. While we know theSetfor a giveneventTypewill only contain callbacks for that specific event, TypeScript’s type system struggles to prove this directly at theMap’s definition level without complex conditional types or type assertions that might overcomplicate theMap’s type itself. Thesubscribemethod’scallbackparameter, however, is strongly typed toEventCallback<T>, so users of the dispatcher get full type safety there."
- Candidate: “Next, we need the
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, theeventparameter in the callback is correctly typed asUserLoggedInEvent, 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 unionAppEventand the genericTinsubscribe.”
Red Flags to Avoid:
- Using
anyexcessively: Especially for event payloads or theEventCallbacktype. - 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 AppEventcorrectly, or not leveragingT['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
- Understand the “Why”: Don’t just memorize syntax. For generics, understand why they solve reusability problems. For unions, understand why they’re better than
anyfor flexible types. For type guards, understand why runtime checks are needed for compile-time safety. - Practice with Real-World Scenarios: The best way to learn is by applying these concepts. Try building:
- A generic
useStatehook for React. - A generic
fetchwrapper. - A configuration loader with discriminated unions.
- A validation utility using custom type guards.
- A generic
- 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.
- 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. - 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.
- Experiment with the TypeScript Playground: It’s an invaluable tool for testing type definitions and seeing how the compiler behaves.
- 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
- TypeScript Handbook - Generics: https://www.typescriptlang.org/docs/handbook/2/generics.html
- TypeScript Handbook - Union and Intersection Types: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types
- TypeScript Handbook - Type Narrowing: https://www.typescriptlang.org/docs/handbook/2/narrowing.html
- TypeScript Deep Dive - Generics: https://basarat.gitbook.io/typescript/type-system/generics
- 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.