Home Interview TypeScript Interview Questions: Types, Generics & Real-World Patterns

TypeScript Interview Questions: Types, Generics & Real-World Patterns

In Plain English 🔥
Imagine you're running a restaurant kitchen. JavaScript is like having cooks who can grab ANY ingredient from ANY shelf with zero labels — fast, but chaotic. TypeScript is like labelling every container: 'this shelf is ONLY for spices, this one ONLY for sauces.' The kitchen still runs the same way at service time (it still compiles to JavaScript), but during prep you catch mistakes before a dish goes out wrong. That's TypeScript — a safety net that lives at development time, not runtime.
⚡ Quick Answer
Imagine you're running a restaurant kitchen. JavaScript is like having cooks who can grab ANY ingredient from ANY shelf with zero labels — fast, but chaotic. TypeScript is like labelling every container: 'this shelf is ONLY for spices, this one ONLY for sauces.' The kitchen still runs the same way at service time (it still compiles to JavaScript), but during prep you catch mistakes before a dish goes out wrong. That's TypeScript — a safety net that lives at development time, not runtime.

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.

TypeVsInterface.ts · TYPESCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243
// ─── 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
▶ Output
@sarah_dev (sarah@example.com)
Admin permissions: read, write, delete
⚠️
Interview Gold:When an interviewer asks 'type or interface?', say: 'I default to interface for object shapes because declaration merging makes them safer for team code and library extensions. I reach for type when I need a union, intersection, or primitive alias — things interfaces literally can't do.' That answer shows you know the trade-offs, not just the syntax.

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(myArray), 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.

Generics.ts · TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// ─── 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
▶ Output
APPLE
98.00
Mouse
29
Order ORD-2024-001: $149.99
⚠️
Watch Out:A common interview trap: 'What's the difference between `` and ``?' With just ``, T could be a primitive (string, number, boolean). Adding `extends object` restricts T to object types only. If your function tries to access a property on T and T could be a primitive, TypeScript will warn you — rightfully so.

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.

UnknownAnyNever.ts · TYPESCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// ─── 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');
▶ Output
DARK
DEFAULT
DEFAULT
Redirecting to PayPal...
Generating wallet address...
🔥
Interview Gold:If an interviewer asks 'when would you use unknown over any?', say: 'Any time data comes from outside my control — API responses, JSON.parse results, or third-party events. unknown forces me to validate the data before using it, which is exactly the right behaviour. any would let me skip validation and ship a runtime crash.' That answer shows production thinking, not just syntax knowledge.

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.

TypeGuards.ts · TYPESCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// ─── 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
▶ Output
Loading... 45% complete
Welcome back, maya_codes!
Error 404: User not found
Network failure [503]: Service unavailable
General error: Something broke
⚠️
Pro Tip:Discriminated unions with a `kind` or `type` field are the TypeScript equivalent of the strategy pattern. They're the go-to for modelling state machines, Redux actions, and WebSocket event types. If you model these correctly, TypeScript prevents you from ever accessing a field that doesn't exist on the current variant — zero runtime `undefined` errors.
Feature / Aspectanyunknown
Type safetyNone — checking disabledFull — must narrow before use
Can assign to typed variableYes, alwaysOnly after narrowing
Autocomplete supportNo — editor goes blindYes, after narrowing
Ideal use caseMigrating legacy JS (temporary)External data — APIs, JSON.parse
Runtime crash riskHigh — no compile-time checksLow — forces validation first
Recommended in productionNo — use as last resortYes — the safe default for unknown data

🎯 Key Takeaways

  • Use interface for object shapes that classes implement or libraries extend — declaration merging is a feature, not a bug. Use type for 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 any where a generic would work.
  • unknown is any with 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 kind field 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 any on API responses to 'fix' TypeScript errors — Symptom: TypeScript stops complaining but you get Cannot read properties of undefined at runtime — Fix: Type your responses with an interface or use unknown and a type guard to validate the shape before accessing properties.
  • Mistake 2: Confusing interface extends with type intersection — Symptom: Developers write type AdminUser = UserProfile & { role: string } expecting it to behave like interface AdminUser extends UserProfile — they're nearly identical for flat objects, but interfaces with methods that have this context behave differently under intersection. Fix: Use interface extends when 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 literal kind field, or use a class instead of an interface when you need runtime instanceof checks.

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.

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousDjango Interview QuestionsNext →Design a Caching System
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged