TypeScript Interview Questions: Types, Generics & Real-World Patterns
TypeScript has gone from a Microsoft experiment to the de facto standard for serious JavaScript projects. React, Angular, Node, and NestJS teams all default to it now — not because it's trendy, but because it catches entire categories of bugs before your code ships. If you're interviewing at any company with a mature frontend or full-stack team, TypeScript fluency isn't optional anymore.
The problem TypeScript solves is subtle but expensive: JavaScript's dynamic typing is powerful but it lets you pass a string where a number was expected, access a property that doesn't exist, or call undefined as a function — all silently, until your user hits the bug in production. TypeScript adds a compile-time type checker that acts like a code reviewer who never sleeps and never misses a thing.
By the end of this article you'll be able to explain the difference between type and interface, use generics to write reusable code without sacrificing type safety, reason about unknown vs any, and walk into an interview ready to answer follow-up questions that trip up even experienced developers.
type vs interface — Which One Do You Actually Reach For?
This is the most common TypeScript interview question, and most candidates answer it wrong — not because they don't know the syntax, but because they can't explain the practical difference.
Both type and interface describe the shape of an object. They're interchangeable in most everyday cases, which is exactly why interviewers ask the question — they want to know if you understand the edges.
The critical difference is that interfaces are open — you can declare the same interface in multiple places and TypeScript merges them. This is called declaration merging. Libraries use this heavily: they ship a type definition and let you extend it in your own code without touching theirs. Types are closed — once declared, they're final.
Types win when you need union types, intersection types, or you're aliasing a primitive. You can't write interface ID = string | number — that's a type job. Interfaces win when you're modelling objects that will be implemented by classes or extended by library consumers.
The rule most senior devs follow: use interface for public API shapes and object contracts; use type for everything else.
// ─── INTERFACE: open, mergeable, ideal for object contracts ─── interface UserProfile { id: number; username: string; } // Declaration merging — TypeScript merges both declarations into one. // Try doing this with `type` and you'll get a duplicate identifier error. interface UserProfile { email: string; // merged in — now UserProfile has id, username AND email } const currentUser: UserProfile = { id: 1, username: 'sarah_dev', email: 'sarah@example.com', // required because of the merged declaration }; // ─── TYPE: closed, flexible, handles unions and primitives ─── type UserID = string | number; // can't do this with interface type AdminUser = UserProfile & { permissions: string[]; // intersection type — combines UserProfile + new fields }; const adminUser: AdminUser = { id: 99, username: 'admin_alex', email: 'alex@example.com', permissions: ['read', 'write', 'delete'], }; // ─── Practical function using both ─── function getUserDisplayName(user: UserProfile): string { // TypeScript knows `user.username` exists because of the interface contract return `@${user.username} (${user.email})`; } console.log(getUserDisplayName(currentUser)); // Output: @sarah_dev (sarah@example.com) console.log(`Admin permissions: ${adminUser.permissions.join(', ')}`); // Output: Admin permissions: read, write, delete
Admin permissions: read, write, delete
Generics — Writing Code That Doesn't Throw Away Type Safety
Generics are the point where a lot of intermediate TypeScript developers get stuck. They understand the syntax but can't explain why you'd use them over just using any.
Here's the problem generics solve: you want to write a function that works with many types, but you want TypeScript to still know what type came out the other end. any throws away that knowledge. Generics preserve it.
Think of a generic as a placeholder that gets filled in at call time. When you call getFirstItem, TypeScript locks in the type as string for that entire call. It checks inputs AND outputs against that locked-in type. With any, TypeScript shrugs and checks nothing.
In real codebases, generics appear constantly: API response wrappers, repository patterns, utility hooks in React, queue data structures, and caching layers. If you've used useState in React, you've already used generics.
The constraint syntax () is especially important — it says 'T can be anything, but it must have at least these properties'. This lets you write flexible utilities without giving up your safety net.
// ─── The problem: without generics, you lose type info ─── function getFirstItemUnsafe(items: any[]): any { return items[0]; // Caller gets `any` back — TypeScript can't help after this point } const maybeAString = getFirstItemUnsafe(['apple', 'banana']); // maybeAString is typed as `any` — no autocomplete, no safety // ─── The solution: generics preserve type information ─── function getFirstItem<T>(items: T[]): T | undefined { // T is a placeholder. TypeScript fills it in based on what you pass. return items.length > 0 ? items[0] : undefined; } const firstFruit = getFirstItem(['apple', 'banana', 'cherry']); // TypeScript infers T = string, so firstFruit is typed as `string | undefined` console.log(firstFruit?.toUpperCase()); // Output: APPLE — .toUpperCase() is valid! const firstScore = getFirstItem([98, 76, 84]); // TypeScript infers T = number, so firstScore is typed as `number | undefined` console.log(firstScore?.toFixed(2)); // Output: 98.00 // ─── Generic with a constraint: T must have an `id` property ─── interface Identifiable { id: number; } function findById<T extends Identifiable>(items: T[], targetId: number): T | undefined { // The constraint `T extends Identifiable` guarantees `item.id` always exists return items.find((item) => item.id === targetId); } const products = [ { id: 1, name: 'Laptop', price: 999 }, { id: 2, name: 'Mouse', price: 29 }, { id: 3, name: 'Keyboard', price: 79 }, ]; const foundProduct = findById(products, 2); // TypeScript knows foundProduct has `id`, `name`, AND `price` — full type preserved console.log(foundProduct?.name); // Output: Mouse console.log(foundProduct?.price); // Output: 29 // ─── Real-world pattern: typed API response wrapper ─── interface ApiResponse<TData> { data: TData; status: number; message: string; } interface OrderSummary { orderId: string; total: number; itemCount: number; } // This function returns a fully typed response — caller knows exactly what's inside function mockFetchOrder(): ApiResponse<OrderSummary> { return { data: { orderId: 'ORD-2024-001', total: 149.99, itemCount: 3 }, status: 200, message: 'OK', }; } const orderResponse = mockFetchOrder(); console.log(`Order ${orderResponse.data.orderId}: $${orderResponse.data.total}`); // Output: Order ORD-2024-001: $149.99
98.00
Mouse
29
Order ORD-2024-001: $149.99
unknown vs any vs never — The Trinity That Defines TypeScript Maturity
Nothing separates a TypeScript beginner from an intermediate developer faster than how they use any. Beginners use it everywhere to 'shut TypeScript up'. Intermediate developers know it's a code smell. Senior developers understand when unknown and never are the correct tools instead.
any is an escape hatch. It turns off type checking entirely for that variable. The compiler will never complain, but you've also lost all the protection TypeScript offers. Using any on an API response is particularly dangerous — you're essentially pretending the data is safe when you haven't validated it.
unknown is the type-safe version of any. It says 'this value exists but I don't know what it is yet — and TypeScript won't let me use it until I prove what it is.' You must narrow it (with typeof, instanceof, or a type guard) before you can do anything with it. This is perfect for data coming from external sources: API responses, JSON parsing, user input.
never is the opposite end of the spectrum — it represents something that can never happen. It's the return type of a function that always throws, or the type of a variable in a branch that TypeScript has proven is unreachable. It's most powerful in exhaustive switch statements — if you add a new union member and forget to handle it, TypeScript will surface a never error that tells you exactly where.
// ─── any: TypeScript goes completely silent — dangerous ─── function parseConfigUnsafe(rawInput: any) { // TypeScript won't stop you doing any of this, even if it crashes at runtime console.log(rawInput.settings.theme.toUpperCase()); // no error, even if undefined } // ─── unknown: safe alternative — must prove the type before using it ─── function parseConfigSafe(rawInput: unknown): string { // You MUST narrow the type before TypeScript lets you touch it if ( typeof rawInput === 'object' && rawInput !== null && 'theme' in rawInput && typeof (rawInput as { theme: unknown }).theme === 'string' ) { // Now TypeScript is satisfied — we've proven `theme` is a string return (rawInput as { theme: string }).theme.toUpperCase(); } return 'DEFAULT'; } console.log(parseConfigSafe({ theme: 'dark' })); // Output: DARK console.log(parseConfigSafe({ theme: 42 })); // Output: DEFAULT console.log(parseConfigSafe(null)); // Output: DEFAULT // ─── never: exhaustive checks — TypeScript tells you when you miss a case ─── type PaymentMethod = 'credit_card' | 'paypal' | 'crypto'; function processPayment(method: PaymentMethod): string { switch (method) { case 'credit_card': return 'Processing credit card charge...'; case 'paypal': return 'Redirecting to PayPal...'; case 'crypto': return 'Generating wallet address...'; default: // This line is the safety net. // If you add 'bank_transfer' to PaymentMethod and forget to handle it, // TypeScript will error HERE because `method` would be `never` in the // default case — and `never` can't be assigned to `never`. const exhaustiveCheck: never = method; throw new Error(`Unhandled payment method: ${exhaustiveCheck}`); } } console.log(processPayment('paypal')); // Output: Redirecting to PayPal... console.log(processPayment('crypto')); // Output: Generating wallet address... // ─── never as a function return type ─── function throwCriticalError(message: string): never { // A function that ALWAYS throws never returns normally. // TypeScript uses `never` to signal: execution ends here. throw new Error(`CRITICAL: ${message}`); } // TypeScript knows code after this call is unreachable // throwCriticalError('Database connection lost');
DEFAULT
DEFAULT
Redirecting to PayPal...
Generating wallet address...
Type Guards & Narrowing — How TypeScript Actually Gets Smarter Mid-Function
Type narrowing is the mechanism that makes TypeScript feel intelligent. You start with a wide type — string | number | null — and through checks inside your function, TypeScript progressively narrows its understanding of what the value actually is.
This isn't magic — TypeScript reads your control flow and updates the type at each branch. After an if (typeof value === 'string') check, TypeScript knows inside that block that value is definitely a string. This is called control flow analysis.
Built-in narrowing uses typeof, instanceof, in, and equality checks. But the really powerful tool is the custom type guard — a function that returns value is SomeType. When that function returns true, TypeScript accepts the narrowing and updates the type in the calling scope.
Type guards are essential when working with discriminated unions — a pattern where each member of a union has a shared kind or type field that uniquely identifies it. This pattern is everywhere in Redux actions, API event types, WebSocket messages, and state machines.
// ─── Discriminated union: each type has a unique `kind` field ─── interface SuccessResponse { kind: 'success'; data: { userId: number; username: string }; } interface ErrorResponse { kind: 'error'; errorCode: number; message: string; } interface LoadingResponse { kind: 'loading'; progress: number; // 0-100 } type ApiState = SuccessResponse | ErrorResponse | LoadingResponse; // ─── Custom type guard using `is` keyword ─── function isSuccessResponse(response: ApiState): response is SuccessResponse { // When this returns true, TypeScript narrows `response` to `SuccessResponse` // in the calling code — even outside this function return response.kind === 'success'; } // ─── Exhaustive handler using narrowing ─── function renderApiState(state: ApiState): string { // TypeScript tracks the type at each branch if (state.kind === 'loading') { // TypeScript knows this is LoadingResponse — `progress` is available return `Loading... ${state.progress}% complete`; } if (isSuccessResponse(state)) { // TypeScript knows this is SuccessResponse — `data` is available return `Welcome back, ${state.data.username}!`; } // TypeScript has narrowed: only ErrorResponse is left // `errorCode` and `message` are both safely accessible here return `Error ${state.errorCode}: ${state.message}`; } // ─── Testing all branches ─── const loadingState: ApiState = { kind: 'loading', progress: 45 }; const successState: ApiState = { kind: 'success', data: { userId: 7, username: 'maya_codes' } }; const errorState: ApiState = { kind: 'error', errorCode: 404, message: 'User not found' }; console.log(renderApiState(loadingState)); // Output: Loading... 45% complete console.log(renderApiState(successState)); // Output: Welcome back, maya_codes! console.log(renderApiState(errorState)); // Output: Error 404: User not found // ─── instanceof guard — useful with class hierarchies ─── class NetworkError extends Error { constructor(public statusCode: number, message: string) { super(message); this.name = 'NetworkError'; } } function handleError(error: unknown): string { if (error instanceof NetworkError) { // TypeScript knows `error` has `statusCode` here return `Network failure [${error.statusCode}]: ${error.message}`; } if (error instanceof Error) { return `General error: ${error.message}`; } return 'Unknown error occurred'; } console.log(handleError(new NetworkError(503, 'Service unavailable'))); // Output: Network failure [503]: Service unavailable console.log(handleError(new Error('Something broke'))); // Output: General error: Something broke
Welcome back, maya_codes!
Error 404: User not found
Network failure [503]: Service unavailable
General error: Something broke
| Feature / Aspect | any | unknown |
|---|---|---|
| Type safety | None — checking disabled | Full — must narrow before use |
| Can assign to typed variable | Yes, always | Only after narrowing |
| Autocomplete support | No — editor goes blind | Yes, after narrowing |
| Ideal use case | Migrating legacy JS (temporary) | External data — APIs, JSON.parse |
| Runtime crash risk | High — no compile-time checks | Low — forces validation first |
| Recommended in production | No — use as last resort | Yes — the safe default for unknown data |
🎯 Key Takeaways
- Use
interfacefor object shapes that classes implement or libraries extend — declaration merging is a feature, not a bug. Usetypefor unions, intersections, and primitive aliases. - Generics preserve type information across function boundaries — they're the difference between 'I know this returns a string' and 'I have no idea what this returns'. Never use
anywhere a generic would work. unknownisanywith a safety lock — the correct default for data from external sources. TypeScript forces you to validate it before use, which is exactly what should happen with untrusted data.- Discriminated unions with a shared
kindfield turn TypeScript's control flow analysis into a compile-time state machine validator — add a new state, TypeScript tells you every switch that needs updating.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using
anyon API responses to 'fix' TypeScript errors — Symptom: TypeScript stops complaining but you getCannot read properties of undefinedat runtime — Fix: Type your responses with an interface or useunknownand a type guard to validate the shape before accessing properties. - ✕Mistake 2: Confusing
interfaceextends withtypeintersection — Symptom: Developers writetype AdminUser = UserProfile & { role: string }expecting it to behave likeinterface AdminUser extends UserProfile— they're nearly identical for flat objects, but interfaces with methods that havethiscontext behave differently under intersection. Fix: Useinterface extendswhen building class hierarchies or when the shape will be implemented by a class; use intersection types for combining plain object shapes. - ✕Mistake 3: Forgetting that TypeScript types are erased at runtime — Symptom: Writing
if (value instanceof MyInterface)causes a compile error because interfaces don't exist at runtime, only types do — Fix: Use discriminated unions with a literalkindfield, or use a class instead of an interface when you need runtimeinstanceofchecks.
Interview Questions on This Topic
- QWhat's the practical difference between `type` and `interface` in TypeScript, and which do you default to in your projects? Can you give a scenario where one simply can't replace the other?
- QExplain how TypeScript's control flow analysis works. If I have a variable typed as `string | null`, what exactly happens inside an `if (value !== null)` block — and how does TypeScript know the type has changed?
- QIf a colleague says 'I'll just use any everywhere to stop TypeScript complaining during a deadline crunch', how do you respond — and what would you suggest instead that doesn't slow them down as much?
Frequently Asked Questions
What is the difference between type and interface in TypeScript?
Both describe object shapes, but interfaces support declaration merging (you can declare the same interface twice and TypeScript combines them) while types are closed once declared. Use interfaces for object contracts and class implementations; use types for unions, intersections, and primitive aliases.
What are TypeScript generics and when should I use them?
Generics are type placeholders that get resolved at call time. Use them whenever you want a function or class to work with multiple types but still preserve type information — for example, a reusable API response wrapper or a typed find-by-id utility. They're the alternative to any that doesn't throw away type safety.
Why is using `any` in TypeScript considered bad practice?
any disables TypeScript's type checker for that variable entirely — you get no autocomplete, no error detection, and no safety. It defeats the purpose of using TypeScript. Use unknown for genuinely uncertain types (forcing you to validate before use) or a proper generic if the type is known at call time.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.