Introduction
Welcome to Chapter 11: Mock Interview Scenarios. This chapter is designed to put your TypeScript knowledge and problem-solving skills to the test in a simulated interview environment. While previous chapters focused on specific concepts and question types, here we integrate everything into realistic, flowing conversations that mimic actual technical interviews.
These scenarios are crucial for candidates targeting mid-level to architect roles, as they demand not just theoretical understanding but also the ability to apply advanced TypeScript features (such as conditional types, mapped types, generics, and tsconfig configurations) to solve real-world architectural and refactoring challenges. By engaging with these mock scenarios, you’ll practice articulating your thought process, making trade-offs, and demonstrating your expertise in a pressure-cooker setting, preparing you for the multifaceted challenges of a modern TypeScript interview in 2026.
Mock Interview Scenario 1: Designing a Robust API Client with Advanced Types
Scenario Setup:
You are interviewing for a Senior TypeScript Engineer role. The interviewer presents a challenge: Your team is building a new microservice that interacts with several external APIs. You need to design a type-safe API client that can handle different request/response structures, potential errors, and authentication headers, all while providing excellent developer experience through strong typing and minimal boilerplate. The goal is to ensure type safety from API definition to consumption.
Interviewer: “Welcome! Let’s dive into a design challenge. Imagine we need to create a robust, type-safe API client in TypeScript. This client will interact with various REST endpoints, some requiring authentication, some returning different data shapes based on input. How would you approach designing the core types and functions to achieve this, specifically leveraging advanced TypeScript features?”
Question 1.1: Core API Request/Response Types
Q: “Let’s start with the basics. How would you define the fundamental types for an API request and response, considering flexibility for different endpoints and potential error states?”
A: “I’d start by defining a generic ApiResponse type that encapsulates both success and error states using a discriminated union. This allows the type system to understand the shape of the data based on a status or success property. For requests, a generic ApiRequestOptions would handle common properties like method, headers, and body, with generics for the payload type.”
// As of TypeScript 5.x
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
interface BaseApiRequestOptions<TBody = unknown> {
method: HttpMethod;
path: string;
headers?: Record<string, string>;
body?: TBody;
// Potentially add query params, timeout, etc.
}
interface SuccessResponse<TData> {
status: 'success';
data: TData;
statusCode: number;
}
interface ErrorResponse {
status: 'error';
message: string;
errorCode?: string;
statusCode: number;
}
type ApiResponse<TData> = SuccessResponse<TData> | ErrorResponse;
// Example usage:
// function fetchData<TRequest, TResponse>(options: BaseApiRequestOptions<TRequest>): Promise<ApiResponse<TResponse>> { ... }
Key Points:
- Discriminated Unions: Essential for type-safe error handling and distinguishing between success and failure states.
- Generics: Allow the
ApiResponseandApiRequestOptionsto be reusable across different data types. unknownfor default payload: Safer thananyforTBodywhen no specific body type is provided, forcing consumers to narrow it.
Common Mistakes:
- Using
anyforTDataorTBody, losing type safety. - Not using discriminated unions, leading to runtime checks for
dataproperty existence. - Over-specifying common types, making them less reusable.
Follow-up: “Excellent. Now, how would you ensure that if an endpoint requires a specific request body, the client function enforces that, and if it doesn’t, the body property is optional or even disallowed?”
Question 1.2: Enforcing Request Body Presence with Conditional Types
Q: “How would you ensure that if an endpoint requires a specific request body, the client function enforces that, and if it doesn’t, the body property is optional or even disallowed?”
A: “This is a perfect use case for conditional types combined with generics. We can define an ApiEndpoint interface that specifies the request body type (TRequestBody) and response type (TResponseBody) for each endpoint. Then, our apiClient function can use a conditional type to make the body property mandatory or optional based on whether TRequestBody is void or unknown.”
// As of TypeScript 5.x
interface ApiEndpoint<TRequestBody, TResponseBody> {
path: string;
method: HttpMethod;
// other metadata like authRequired, description etc.
}
// A utility type to make 'body' required if TRequestBody is not 'void' or 'undefined'
type RequestOptions<TRequestBody> = TRequestBody extends void | undefined
? Omit<BaseApiRequestOptions<void>, 'body'> // No body allowed
: BaseApiRequestOptions<TRequestBody>; // Body required and typed
function apiClient<TRequestBody, TResponseBody>(
endpoint: ApiEndpoint<TRequestBody, TResponseBody>,
options: RequestOptions<TRequestBody>
): Promise<ApiResponse<TResponseBody>> {
// Simulate API call
console.log(`Calling ${endpoint.method} ${endpoint.path} with options:`, options);
return Promise.resolve({ status: 'success', data: {} as TResponseBody, statusCode: 200 }); // Placeholder
}
// Define some example endpoints
const getUserEndpoint: ApiEndpoint<void, { id: string; name: string }> = {
path: '/users/{id}',
method: 'GET',
};
const createUserEndpoint: ApiEndpoint<{ name: string; email: string }, { id: string }> = {
path: '/users',
method: 'POST',
};
// Usage examples:
apiClient(getUserEndpoint, { headers: { Authorization: 'Bearer token' } }); // OK, no body
// apiClient(getUserEndpoint, { body: { some: 'data' } }); // Error: Object literal may not specify known properties, body does not exist.
apiClient(createUserEndpoint, { body: { name: 'Alice', email: '[email protected]' } }); // OK, body required
// apiClient(createUserEndpoint, { headers: { 'Content-Type': 'application/json' } }); // Error: Property 'body' is missing
Key Points:
- Conditional Types (
extends): Powerfully used to branch types based on a condition (e.g.,TRequestBody extends void). voidas a signal: Usingvoidto explicitly indicate “no body” is a common pattern for type safety.OmitUtility Type: Useful for removing specific properties from a type.
Common Mistakes:
- Using
anyfor body and then manually checking presence. - Not explicitly defining
voidfor endpoints without a body, leading tounknownorundefinedbeing implicitly allowed. - Complex conditional types that become hard to read; keeping them focused is key.
Follow-up: “Very good. Now, consider a scenario where we have many endpoints. Manually defining ApiEndpoint for each can be tedious. How can we improve developer experience by inferring these types, perhaps from a schema or a more concise definition?”
Question 1.3: Inferring Endpoint Types with Mapped Types and Generics
Q: “Consider a scenario where we have many endpoints. Manually defining ApiEndpoint for each can be tedious. How can we improve developer experience by inferring these types, perhaps from a schema or a more concise definition?”
A: “We can create a central ApiSchema object where developers define endpoints concisely. Then, we can use mapped types to transform this schema into a strongly-typed ApiEndpoints object, from which our apiClient can infer the correct request and response types. This provides a single source of truth and excellent auto-completion.”
// As of TypeScript 5.x
interface EndpointDefinition {
method: HttpMethod;
path: string;
requestBody?: unknown; // Use unknown as default, can be void or specific type
responseBody: unknown;
}
// Define the API schema
interface AppApiSchema {
getUser: {
method: 'GET';
path: '/users/{id}';
responseBody: { id: string; name: string };
};
createUser: {
method: 'POST';
path: '/users';
requestBody: { name: string; email: string };
responseBody: { id: string };
};
updateUser: {
method: 'PUT';
path: '/users/{id}';
requestBody: { name?: string; email?: string };
responseBody: { success: boolean };
};
}
// Utility type to extract request and response body for a given endpoint definition
type GetRequestBody<T extends EndpointDefinition> = T extends { requestBody: infer R }
? R
: void; // Default to void if no requestBody property
type GetResponseBody<T extends EndpointDefinition> = T extends { responseBody: infer R }
? R
: never; // Should always have a responseBody, otherwise type error
// Map the schema to a type that the apiClient can use
type MappedEndpoints<Schema extends Record<string, EndpointDefinition>> = {
[K in keyof Schema]: ApiEndpoint<
GetRequestBody<Schema[K]>,
GetResponseBody<Schema[K]>
>;
};
const API_ENDPOINTS: MappedEndpoints<AppApiSchema> = {
getUser: { method: 'GET', path: '/users/{id}' },
createUser: { method: 'POST', path: '/users' },
updateUser: { method: 'PUT', path: '/users/{id}' },
};
// Refined apiClient to take an endpoint key
function typedApiClient<
K extends keyof AppApiSchema,
ReqBody = GetRequestBody<AppApiSchema[K]>,
ResBody = GetResponseBody<AppApiSchema[K]>
>(
endpointKey: K,
options: RequestOptions<ReqBody>
): Promise<ApiResponse<ResBody>> {
const endpoint = API_ENDPOINTS[endpointKey];
// Actual fetch logic here
console.log(`Calling ${endpoint.method} ${endpoint.path} via key ${String(endpointKey)} with options:`, options);
return Promise.resolve({ status: 'success', data: {} as ResBody, statusCode: 200 });
}
// Usage:
typedApiClient('getUser', {}); // OK
// typedApiClient('getUser', { body: {} }); // Error
typedApiClient('createUser', { body: { name: 'Bob', email: '[email protected]' } }); // OK
// typedApiClient('createUser', {}); // Error: Property 'body' is missing
Key Points:
- Mapped Types:
[K in keyof Schema]allows iterating over properties of an object type and transforming them. inferKeyword: Crucial for extracting types from conditional types.- Single Source of Truth: Centralizing API definitions improves consistency and maintainability.
- Improved DX: Auto-completion for endpoint keys and strict type checking for request bodies.
Common Mistakes:
- Over-complicating the schema definition. Start simple and add complexity as needed.
- Not using
infercorrectly, leading toanyor less precise types. - Forgetting to handle the
voidcase forrequestBodywhen extracting.
Follow-up: “This is a robust design. Now, let’s switch gears slightly. As an architect, you’re also responsible for tsconfig.json. What are some critical tsconfig options you’d enforce for a large, production-grade TypeScript monorepo, and why?”
Question 1.4: tsconfig.json for a Large Monorepo
Q: “As an architect, you’re also responsible for tsconfig.json. What are some critical tsconfig options you’d enforce for a large, production-grade TypeScript monorepo, and why?”
A: “For a large, production-grade TypeScript monorepo (as of TS 5.x), a robust tsconfig.json is paramount for type safety, maintainability, and build performance. Here are some critical options I’d enforce:”
"strict": true:- Why: This is the absolute cornerstone. It enables all strict type-checking options (
noImplicitAny,noImplicitReturns,noImplicitThis,strictNullChecks,strictFunctionTypes,strictPropertyInitialization,alwaysStrict). It catches a vast majority of common type-related bugs early. While it can be a significant upfront investment for existing JS codebases, for new projects or refactoring, it’s non-negotiable for long-term stability and code quality.
- Why: This is the absolute cornerstone. It enables all strict type-checking options (
"noEmitOnError": true:- Why: Prevents TypeScript from emitting JavaScript files if there are any type errors. This ensures that only type-safe code makes it into your build artifacts, preventing deployment of broken code.
"esModuleInterop": true:- Why: Allows for cleaner interoperability between CommonJS and ES Modules. It creates namespace objects for all imports, which can simplify module consumption, especially when dealing with mixed module systems common in large projects.
"forceConsistentCasingInFileNames": true:- Why: Prevents issues that can arise from inconsistent casing in file paths (e.g.,
Component.tsvscomponent.ts). This is especially important for cross-platform development (Windows is case-insensitive, Linux/macOS are case-sensitive) and ensures consistent module resolution.
- Why: Prevents issues that can arise from inconsistent casing in file paths (e.g.,
"isolatedModules": true:- Why: Ensures that each file can be compiled independently without needing information about other files. This is crucial for build tools like Babel or
ts-node --transpile-onlythat perform single-file transpilation. It helps prevent runtime errors that might occur if a file isn’t self-contained (e.g., re-exporting a type that isn’t imported).
- Why: Ensures that each file can be compiled independently without needing information about other files. This is crucial for build tools like Babel or
"moduleResolution": "bundler"(or"node16"/"nodenext"):- Why: As of TS 5.x,
"bundler"is often the most appropriate option for modern web projects using bundlers like Webpack, Rollup, or Vite. It tries to emulate how bundlers resolve modules, offering a good balance between classic Node.js resolution and modern ESM semantics. For Node.js projects,"node16"or"nodenext"align directly with Node.js’s native ESM resolution algorithm. Choosing the correct module resolution is vital for preventing runtime import errors.
- Why: As of TS 5.x,
"allowSyntheticDefaultImports": true:- Why: Works in conjunction with
esModuleInteropto allow default imports from modules that don’t explicitly have a default export. This makes consuming CommonJS modules with ES Module syntax much smoother.
- Why: Works in conjunction with
"paths"and"baseUrl":- Why: In a monorepo, these are essential for creating module aliases (e.g.,
@shared/utilsmapping topackages/shared/src/utils). This simplifies imports, makes refactoring easier, and improves maintainability by decoupling internal module paths from their physical location.
- Why: In a monorepo, these are essential for creating module aliases (e.g.,
"declaration": trueand"declarationMap": true:- Why: For libraries or shared packages within a monorepo, these options generate
.d.tsdeclaration files and their source maps. This allows other projects to consume the package with full type safety and provides better debugging experiences when stepping through declaration files.
- Why: For libraries or shared packages within a monorepo, these options generate
"exactOptionalPropertyTypes": true:- Why: (Introduced in TS 4.4) This makes optional properties stricter. It enforces that if a property is optional, it must be either present with its defined type or entirely absent. It disallows
undefinedas a valid value ifundefinedis not explicitly part of the property’s type. This can prevent subtle bugs related toundefinedassignment.
- Why: (Introduced in TS 4.4) This makes optional properties stricter. It enforces that if a property is optional, it must be either present with its defined type or entirely absent. It disallows
Key Points:
- Strictness First:
strict: trueis foundational for preventing bugs. - Build Integrity: Options like
noEmitOnErrorandisolatedModulesensure a robust build process. - Module Interoperability:
esModuleInteropandmoduleResolutionare crucial for modern JS ecosystems. - Developer Experience:
pathsimprove import ergonomics,declarationfor shared libraries.
Common Mistakes:
- Not enabling
strict: truein new projects due to perceived complexity. - Ignoring
moduleResolutionand encountering runtime import issues. - Forgetting
isolatedModuleswhen using build tools that transpile files independently. - Overlooking
forceConsistentCasingInFileNames, leading to build failures on different OS.
Follow-up: “Excellent overview. Given your emphasis on strict: true, how would you handle migrating an existing large JavaScript codebase to TypeScript, particularly when it has many implicit any types and loose patterns that would immediately break under strict mode? What’s your strategy?”
Mock Interview Scenario 2: Refactoring a Legacy JavaScript Module to TypeScript
Scenario Setup:
You are interviewing for an Architect position. The team has a critical legacy JavaScript module that handles user data processing, including validation, transformation, and persistence. This module is notoriously hard to maintain due to its dynamic nature and lack of type safety. Your task is to lead the migration of this module to TypeScript, introducing robust types without rewriting the entire logic from scratch.
Interviewer: “Welcome back. For this scenario, let’s tackle a common architectural challenge: migrating a complex, legacy JavaScript module to TypeScript. This module processes user data, which can come in various shapes and forms, and performs several transformations. How would you approach this migration, focusing on incremental adoption and leveraging advanced TypeScript features to harden the module?”
Question 2.1: Incremental Migration Strategy
Q: “What’s your strategy for incrementally migrating this legacy JavaScript module to TypeScript, especially given its complexity and dynamic nature? How do you ensure stability during the transition?”
A: “Incremental migration is key for complex legacy codebases. My strategy would involve:”
- Start with
allowJsandcheckJs:- Add a
tsconfig.jsonto the module, enablingallowJs: trueandcheckJs: true. This allows TypeScript to parse and type-check existing JavaScript files, identifying basic errors and providing initial type inference. - Set
noEmit: trueinitially if we’re not ready to compile TS files, just for type checking.
- Add a
- Enable
strictNullChecksandnoImplicitAnygradually:- Instead of
strict: trueinitially, I’d enablenoImplicitAny: truefirst. For existinganys, I’d use JSDoc comments (@ts-ignore,@ts-expect-error) judiciously or explicitly type them asanyorunknownas placeholders. - Then, introduce
strictNullChecks: true, which often uncovers many potential runtime errors.
- Instead of
- Create
.d.tsdeclaration files for untyped dependencies:- If the module depends on external JavaScript libraries without
@types/packages, I’d create minimal.d.tsfiles for their interfaces to provide basic type safety.
- If the module depends on external JavaScript libraries without
- Convert files one by one (or by logical grouping):
- Identify low-hanging fruit or critical, well-understood parts of the module. Convert
.jsfiles to.ts(or.tsx). - Focus on defining clear input/output types for functions and data structures within these files.
- Identify low-hanging fruit or critical, well-understood parts of the module. Convert
- Use
// @ts-nocheckand// @ts-ignorestrategically:- For highly complex or problematic files,
// @ts-nocheckcan temporarily disable type checking, allowing other files to be converted. This should be a temporary measure, with a plan to remove it. // @ts-ignorefor specific lines can help unblock progress but should be accompanied by comments explaining why and a plan for resolution.
- For highly complex or problematic files,
- Integrate into CI/CD:
- Ensure type checking is part of the CI pipeline. Gradually increase the strictness level as more code is migrated.
- Test thoroughly:
- Maintain robust unit and integration tests. The migration should not break existing functionality; tests provide a safety net.
- Leverage editor tooling:
- Encourage developers to use IDEs with strong TypeScript support (VS Code) to benefit from type inference, auto-completion, and refactoring tools.
Key Points:
- Incremental Approach: Avoid a “big bang” rewrite; migrate piece by piece.
- Tooling: Use
allowJs,checkJs, JSDoc comments,ts-ignore/ts-nocheckas stepping stones. - Prioritization: Start with critical paths or well-defined interfaces.
- Testing: Critical for ensuring no regressions during migration.
Common Mistakes:
- Trying to enable
strict: trueon the entire legacy codebase at once. - Overusing
anyorts-ignorewithout a plan to resolve them. - Neglecting to add
tsconfig.jsonand type-checking to CI early on. - Not communicating the strategy and benefits to the team, leading to resistance.
Follow-up: “Great. Now let’s get into the specifics of typing. This module processes diverse user data. How would you define a type that represents a ‘User’ but allows for different optional properties based on context, and how would you handle data transformation and validation in a type-safe manner?”
Question 2.2: Advanced User Type Definition and Data Transformation
Q: “How would you define a type that represents a ‘User’ but allows for different optional properties based on context (e.g., UserWithAddress vs. UserWithPreferences), and how would you handle data transformation and validation in a type-safe manner?”
A: “To handle varying user data shapes, I’d use a combination of interface extensions, intersection types, and utility types. For transformation and validation, I’d leverage type guards and potentially a validation library with TypeScript support (like Zod or Yup) to infer types from schemas.”
// As of TypeScript 5.x
// Base User interface
interface BaseUser {
id: string;
name: string;
email: string;
}
// Specific contexts extending BaseUser
interface UserWithAddress extends BaseUser {
address: {
street: string;
city: string;
zipCode: string;
};
}
interface UserWithPreferences extends BaseUser {
preferences: {
theme: 'dark' | 'light';
notifications: boolean;
};
}
// A flexible User type using intersection and Partial for optional contexts
type UserProfile = BaseUser & Partial<UserWithAddress & UserWithPreferences>;
// Example of a function that takes a UserProfile
function displayUserProfile(user: UserProfile) {
console.log(`User: ${user.name} (${user.email})`);
if (user.address) { // Type narrowing in action
console.log(`Address: ${user.address.street}, ${user.address.city}`);
}
if (user.preferences) {
console.log(`Theme: ${user.preferences.theme}`);
}
}
const user1: UserProfile = { id: '1', name: 'Alice', email: '[email protected]' };
const user2: UserProfile = {
id: '2',
name: 'Bob',
email: '[email protected]',
address: { street: '123 Main St', city: 'Anytown', zipCode: '12345' },
};
displayUserProfile(user1);
displayUserProfile(user2);
// Data Transformation and Validation (using Zod as an example for 2026)
import { z } from 'zod'; // Assuming Zod is a popular choice for schema validation
// Define a Zod schema for BaseUser
const BaseUserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(50),
email: z.string().email(),
});
// Define schemas for optional parts
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
});
const PreferencesSchema = z.object({
theme: z.union([z.literal('dark'), z.literal('light')]),
notifications: z.boolean(),
});
// Combine schemas for the full UserProfile, making address and preferences optional
const UserProfileSchema = BaseUserSchema.extend({
address: AddressSchema.optional(),
preferences: PreferencesSchema.optional(),
});
// Infer the TypeScript type directly from the Zod schema
type ValidatedUserProfile = z.infer<typeof UserProfileSchema>;
function processUserData(rawData: unknown): ValidatedUserProfile {
try {
// This performs validation and returns a type-safe object
const validatedData = UserProfileSchema.parse(rawData);
// Perform transformations if needed
// For example, normalize email to lowercase
validatedData.email = validatedData.email.toLowerCase();
return validatedData;
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Validation failed:', error.errors);
throw new Error('Invalid user data');
}
throw error;
}
}
// Example usage:
const goodData = {
id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
name: 'Charlie',
email: '[email protected]',
address: { street: '456 Oak Ave', city: 'Sometown', zipCode: '67890' },
};
const processedCharlie = processUserData(goodData);
console.log(processedCharlie.email); // [email protected]
// console.log(processedCharlie.address.street); // Type-safe access
const badData = {
id: 'invalid-uuid',
name: 'D',
email: 'invalid-email',
};
// processUserData(badData); // Throws "Invalid user data" due to Zod validation
Key Points:
- Interface Extensions and Intersection Types: Build complex types from simpler, reusable parts.
Partial<T>: Makes all properties of a type optional, useful for flexible profiles.- Type Narrowing: TypeScript automatically narrows types within
ifblocks (e.g.,if (user.address)). - Schema Validation Libraries (Zod, Yup): Crucial for runtime validation, and their
.infercapabilities bridge the gap between runtime validation and compile-time types. unknownfor raw input: Safely handles untyped external data before validation.
Common Mistakes:
- Using
anyforrawDataand manually validating, losing type safety guarantees. - Creating redundant types for validation and TypeScript, leading to maintenance overhead.
- Not distinguishing between compile-time type safety and runtime validation.
- Ignoring
undefinedornullpossibilities when accessing optional properties without narrowing.
Follow-up: “Excellent use of modern TypeScript and validation libraries. Now, this module also deals with different event types that can trigger various user data updates. How would you design a type-safe event system using discriminated unions and type guards, ensuring that listeners only receive data relevant to their event type?”
Question 2.3: Type-Safe Event System with Discriminated Unions
Q: “This module also deals with different event types that can trigger various user data updates. How would you design a type-safe event system using discriminated unions and type guards, ensuring that listeners only receive data relevant to their event type?”
A: “A type-safe event system is a perfect candidate for discriminated unions and conditional types. I’d define a union of all possible event types, each with a unique type literal property. An event emitter can then be typed to dispatch these unions, and listeners can use type guards or conditional types to ensure they only process the data shape specific to the event they are interested in.”
// As of TypeScript 5.x
// Define specific event types using a 'type' discriminator
interface UserCreatedEvent {
type: 'UserCreated';
payload: { userId: string; name: string; email: string };
timestamp: Date;
}
interface UserUpdatedEvent {
type: 'UserUpdated';
payload: { userId: string; changes: Partial<{ name: string; email: string; address: any }> };
timestamp: Date;
}
interface UserDeletedEvent {
type: 'UserDeleted';
payload: { userId: string };
timestamp: Date;
}
// Union type of all possible user events
type UserEvent = UserCreatedEvent | UserUpdatedEvent | UserDeletedEvent;
// Type for mapping event names to their payload types for listeners
type EventPayloadMap = {
[K in UserEvent['type']]: Extract<UserEvent, { type: K }>['payload'];
};
// Generic Event Emitter Interface
interface EventEmitter<TEventMap extends Record<string, any>> {
on<K extends keyof TEventMap>(eventName: K, listener: (payload: TEventMap[K]) => void): void;
emit<K extends keyof TEventMap>(eventName: K, payload: TEventMap[K]): void;
off<K extends keyof TEventMap>(eventName: K, listener: (payload: TEventMap[K]) => void): void;
}
// Concrete implementation (simplified)
class UserEventEmitter implements EventEmitter<EventPayloadMap> {
private listeners: { [K in keyof EventPayloadMap]?: Array<(payload: EventPayloadMap[K]) => void> } = {};
on<K extends keyof EventPayloadMap>(eventName: K, listener: (payload: EventPayloadMap[K]) => void): void {
if (!this.listeners[eventName]) {
this.listeners[eventName] = [];
}
(this.listeners[eventName] as Array<(payload: EventPayloadMap[K]) => void>).push(listener);
}
emit<K extends keyof EventPayloadMap>(eventName: K, payload: EventPayloadMap[K]): void {
this.listeners[eventName]?.forEach(listener => listener(payload));
}
off<K extends keyof EventPayloadMap>(eventName: K, listener: (payload: EventPayloadMap[K]) => void): void {
if (this.listeners[eventName]) {
this.listeners[eventName] = (this.listeners[eventName] as Array<(payload: EventPayloadMap[K]) => void>).filter(l => l !== listener) as any;
}
}
}
const userEvents = new UserEventEmitter();
// Registering listeners with type safety
userEvents.on('UserCreated', (payload) => {
// payload is automatically inferred as UserCreatedEvent['payload']
console.log(`User created: ${payload.name} (${payload.userId})`);
// console.log(payload.changes); // Error: Property 'changes' does not exist
});
userEvents.on('UserUpdated', (payload) => {
// payload is automatically inferred as UserUpdatedEvent['payload']
console.log(`User updated: ${payload.userId}, changes: ${JSON.stringify(payload.changes)}`);
});
// Emitting events
userEvents.emit('UserCreated', { userId: 'u123', name: 'Eve', email: '[email protected]', timestamp: new Date() });
userEvents.emit('UserUpdated', { userId: 'u123', changes: { name: 'Eve Smith' }, timestamp: new Date() });
// userEvents.emit('UserDeleted', { userId: 'u456', name: 'Frank' }); // Error: Object literal may only specify known properties
Key Points:
- Discriminated Unions for Events: Each event type has a literal
typeproperty, enabling TypeScript to differentiate them. Extract<T, U>Utility Type: Used to extract a subset of a union type, ensuring theEventPayloadMapcorrectly maps event names to their specific payloads.- Generic
EventEmitter: Makes the event system reusable for different sets of events. - Type Inference in Listeners: TypeScript automatically infers the correct payload type for listeners based on the event name.
Common Mistakes:
- Using
anyfor event payloads, losing all type safety. - Not using a discriminator (
typeproperty), making it impossible for TypeScript to narrow the union. - Manually casting types within listeners, which defeats the purpose of type safety.
- Overlooking edge cases where the
listenersarray might beundefinedornullwithout proper checks.
MCQ Section: Advanced TypeScript Concepts
Choose the best answer for each question.
1. Which tsconfig.json option, introduced in TypeScript 4.4, provides stricter checking for optional properties by disallowing undefined as a valid value unless explicitly typed?
A) "noUncheckedIndexedAccess"
B) "exactOptionalPropertyTypes"
C) "noPropertyAccessFromIndexSignature"
D) "noImplicitOverride"
**Correct Answer: B**
* **Explanation:** `"exactOptionalPropertyTypes"` enforces that an optional property must either be present with its defined type or entirely absent. If `undefined` is intended to be a valid value, it must be explicitly included in the property's type (e.g., `prop?: string | undefined`).
* A) `noUncheckedIndexedAccess` makes indexed access (`obj[key]`) return `T | undefined`.
* C) `noPropertyAccessFromIndexSignature` prevents dot notation access on types with string index signatures.
* D) `noImplicitOverride` ensures methods overriding base class methods are explicitly marked with `override`.
2. You have a type type Result<T> = { success: true; data: T } | { success: false; error: string };. Which utility type would you use to extract only the success case’s data type, T?
A) Extract<Result<T>, { success: true }>['data']
B) NonNullable<Result<T>>['data']
C) Pick<Result<T>, 'data'>
D) Exclude<Result<T>, { success: false }>['data']
**Correct Answer: A**
* **Explanation:** `Extract<T, U>` constructs a type by extracting from `T` all union members that are assignable to `U`. Here, `Extract<Result<T>, { success: true }>` will yield `{ success: true; data: T }`. Accessing `['data']` then gives `T`.
* B) `NonNullable` removes `null` and `undefined` from a type.
* C) `Pick` constructs a type by picking the set of properties `K` from `T`. It would apply to the entire `Result<T>` union, not just the success case.
* D) `Exclude<T, U>` constructs a type by excluding from `T` all union members that are assignable to `U`. While `Exclude` would work to get `{ success: true; data: T }`, `Extract` is generally more direct when you want to *keep* specific union members.
3. In TypeScript 5.x, what is the recommended moduleResolution strategy for modern web projects using bundlers like Webpack or Vite?
A) "node"
B) "classic"
C) "bundler"
D) "nodenext"
**Correct Answer: C**
* **Explanation:** As of TypeScript 5.x, `"bundler"` is designed to better emulate how modern JavaScript bundlers resolve modules, offering a more accurate and robust experience for web projects. `"node"` is for Node.js CommonJS, `"classic"` is legacy, and `"nodenext"`/`"node16"` are for native Node.js ESM.
4. You are refactoring a JavaScript module that uses export = for CommonJS style exports. When migrating this to TypeScript and consuming it from an ES Module context, which tsconfig.json option is crucial for proper interoperability?
A) "resolveJsonModule"
B) "isolatedModules"
C) "esModuleInterop"
D) "allowUmdGlobalAccess"
**Correct Answer: C**
* **Explanation:** `"esModuleInterop": true` is essential for enabling better interoperability between CommonJS modules (which `export =` typically represents) and ES Modules. It allows you to use `import MyModule from 'my-module';` even if `my-module` only has a CommonJS `export =` or `module.exports`.
* A) `resolveJsonModule` allows importing `.json` files.
* B) `isolatedModules` ensures files can be compiled independently.
* D) `allowUmdGlobalAccess` allows accessing UMD globals from modules.
5. You have a function that takes an argument of type unknown. To safely use this argument as a string[], which TypeScript feature is most appropriate?
A) Type assertion (arg as string[]) without checks.
B) Using any as an intermediate type.
C) A user-defined type guard (isStringArray(arg): arg is string[]).
D) typeof arg === 'object' && Array.isArray(arg) followed by a type assertion.
**Correct Answer: C**
* **Explanation:** A user-defined type guard provides runtime checks that inform the TypeScript compiler about the type within a specific scope. It's the safest and most robust way to narrow `unknown` to a specific, complex type like `string[]`.
* A) Type assertion without checks is unsafe and can lead to runtime errors if the type is incorrect.
* B) Using `any` defeats the purpose of type safety.
* D) While `typeof arg === 'object' && Array.isArray(arg)` is a good runtime check, it doesn't fully inform TypeScript that the *elements* of the array are `string`. A type guard can recursively check elements or use a validation library.
Practical Tips for Mock Interviews
- Think Out Loud: This is the most critical advice. Interviewers want to understand your thought process, not just your final answer. Explain your assumptions, your chosen approach, alternatives you considered, and why you made specific decisions.
- Ask Clarifying Questions: Don’t be afraid to ask for more details. What are the constraints? What are the performance requirements? Are there any specific libraries or frameworks in use? This demonstrates critical thinking and problem-solving.
- Start Simple, Then Elaborate: For complex design questions, begin with a basic, working solution or a high-level overview. Then, progressively add complexity, discussing edge cases, error handling, and advanced features.
- Know Your
tsconfig.json: Be prepared to discuss key compiler options and their implications for large projects, performance, and type safety. This is a common architectural discussion point. - Master Advanced Types: Conditional types, mapped types, and generics are frequently used in architect-level questions. Understand not just what they are, but when and why to use them for robust, maintainable code.
- Practice Real-World Scenarios: Don’t just memorize definitions. Think about how you’d apply TypeScript to common problems like API client design, state management, event systems, or migrating legacy code.
- Be Ready for Trade-offs: Architectural decisions often involve trade-offs. Be able to discuss the pros and cons of different approaches (e.g., performance vs. type safety, complexity vs. flexibility).
- Stay Updated (TypeScript 5.x): Mentioning recent features like
satisfiesoperator, new decorator support (experimental as of TS 5.0), or changes inmoduleResolutionshows you’re current.
Summary
This chapter provided an immersive experience into mock TypeScript interviews, simulating the types of questions and discussions you might encounter for mid-to-architect level roles. We covered:
- Scenario-Based Problem Solving: Applying advanced TypeScript features to design a type-safe API client and refactor a legacy module.
- Deep Dives: Enforcing request body presence with conditional types, inferring types with mapped types, and designing robust
tsconfig.jsonfiles for monorepos. - Type-Safe Patterns: Using discriminated unions for flexible data structures and event systems, combined with validation libraries like Zod.
- Strategic Thinking: Approaching incremental migrations and making architectural trade-offs.
By practicing these scenarios, you gain confidence in articulating your technical decisions and demonstrating your expertise in advanced TypeScript. Remember to always think out loud, ask clarifying questions, and show your ability to apply theoretical knowledge to practical problems.
References
- TypeScript Official Documentation (2026-01-14): https://www.typescriptlang.org/docs/ - The authoritative source for all TypeScript features and
tsconfigoptions. - Zod - Schema Declaration and Validation: https://zod.dev/ - A popular TypeScript-first schema validation library.
- Advanced TypeScript: Mapped Types and Conditional Types: https://tianyaschool.medium.com/typescript-advanced-types-mapped-types-and-conditional-types-e340239d3249 - A Medium article discussing these advanced type features (as of Nov 2025).
- TypeScript Handbook - Utility Types: https://www.typescriptlang.org/docs/handbook/utility-types.html - Essential for understanding
Partial,Pick,Omit,Extract,Exclude, etc. - InterviewBit - Technical Interview Questions: https://www.interviewbit.com/technical-interview-questions/ - General resource for technical interview preparation strategies.
- GitHub - charlax/professional-programming: https://github.com/charlax/professional-programming - A collection of full-stack resources including programming concepts.
This interview preparation guide is AI-assisted and reviewed. It references official documentation and recognized interview preparation resources.