TypeScript interview questions
Quick reference
Section titled “Quick reference”| Question | One-line answer |
|---|---|
| Structural vs nominal typing | TS checks shape, not name. Two types with same fields are compatible. |
any vs unknown |
any bypasses checks. unknown requires narrowing before use. Always prefer unknown. |
| Generics | Type parameters that preserve relationships. function first<T>(arr: T[]): T. |
| Utility types | Built-in type transformers. Partial, Pick, Omit, Record, Required, Readonly. |
| Discriminated unions | Union with a shared literal field. Switch on it for exhaustive type narrowing. |
| Type assertion vs type guard | Assertion (as) trusts you. Guard (typeof, in, custom) verifies at runtime. |
interface vs type |
type is more flexible (unions, intersections). interface supports declaration merging. |
| Branded types | Phantom field makes structurally identical types distinct. UserId vs ProductId. |
never |
Impossible type. Used for exhaustiveness checking in switch/if chains. |
| Conditional types | Type-level ternary. T extends string ? A : B. Used for type transformations. |
Full answers
Section titled “Full answers”Structural vs nominal typing
Section titled “Structural vs nominal typing”TypeScript uses structural typing. If two types have the same shape, they’re compatible regardless of their names:
type Dog = { name: string; age: number };type Cat = { name: string; age: number };
function greet(animal: Dog) { console.log(animal.name);}
const cat: Cat = { name: "Whiskers", age: 5 };greet(cat); // works — Cat has the same shape as DogThis is different from Java/C# where Dog and Cat would be incompatible even with identical fields. TypeScript’s perspective: the function only needs name and age. The Cat has both. It will work. Why should the name matter?
The edge case: when two types are structurally identical but semantically different (a UserId and OrderId are both strings). Fix with branded types (see below).
Deeper: Type system mental model
any vs unknown
Section titled “any vs unknown”Both accept any value. The difference is what you can do afterward.
any turns off the type checker. You can call methods, access properties, assign it to anything. No errors, no safety.
const x: any = "hello";x.foo.bar.baz(); // no error — any disables all checksconst n: number = x; // no error — any is assignable to everythingunknown requires narrowing before use. You have to prove what it is before using it:
const x: unknown = "hello";x.toUpperCase(); // error: unknown doesn't have toUpperCase
if (typeof x === "string") { x.toUpperCase(); // works — narrowed to string}The rule: use unknown for values you don’t know the type of (API responses, JSON.parse output, catch block errors). Use any only as a last resort during migration from JavaScript.
When would you use generics over union types?
Section titled “When would you use generics over union types?”Generics preserve the relationship between input and output. Unions don’t.
// Union: you know it's string OR number, but you lose which onefunction identity(val: string | number): string | number { return val;}const result = identity("hello"); // string | number — too wide
// Generic: the specific type flows throughfunction identity<T>(val: T): T { return val;}const result = identity("hello"); // string — exact type preservedUse generics when the return type depends on the input type. Use unions when the return type is fixed regardless of input.
Discriminated unions with exhaustiveness checking
Section titled “Discriminated unions with exhaustiveness checking”The pattern that catches missing cases at compile time:
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "square": return shape.side ** 2; case "triangle": return 0.5 * shape.base * shape.height; default: const _exhaustive: never = shape; return _exhaustive; }}If someone adds { kind: "pentagon"; ... } to the union, the default case fails to compile because shape is not never (the pentagon is unhandled). The developer is forced to add a case.
This is the TypeScript equivalent of Rust’s exhaustive match. It’s the most practical advanced pattern for state machines, action handlers, and API response types.
Deeper: Advanced patterns
Branded types for type-safe IDs
Section titled “Branded types for type-safe IDs”type UserId = string & { readonly __brand: "UserId" };type OrderId = string & { readonly __brand: "OrderId" };
function createUserId(id: string): UserId { return id as UserId;}
function getUser(id: UserId) { /* ... */}function getOrder(id: OrderId) { /* ... */}
const userId = createUserId("u_123");const orderId = createOrderId("o_456");
getUser(userId); // worksgetUser(orderId); // compile error: OrderId is not UserIdgetUser("raw"); // compile error: string is not UserIdThe __brand property doesn’t exist at runtime. It’s a compile-time marker that makes structurally identical types nominally distinct. Factory functions are the only entry point. Everywhere else, the type system enforces the distinction.
Practical: typing API responses
Section titled “Practical: typing API responses”// Define the API response shape oncetype ApiResponse<T> = { status: "success"; data: T } | { status: "error"; error: string; code: number };
// Usage: the response type narrows based on status checkasync function fetchUser(id: string): Promise<ApiResponse<User>> { const res = await fetch(`/api/users/${id}`); if (!res.ok) return { status: "error", error: "Not found", code: res.status }; return { status: "success", data: await res.json() };}
const result = await fetchUser("123");if (result.status === "success") { console.log(result.data.name); // data is User, fully typed} else { console.log(result.error); // error is string}This combines discriminated unions with generics. The generic T flows into the success branch. The discriminant status enables narrowing. The caller can’t access data without checking status first.
Practical: type-safe event emitter
Section titled “Practical: type-safe event emitter”type Events = { "user:login": { userId: string; timestamp: Date }; "user:logout": { userId: string }; "page:view": { path: string; referrer: string | null };};
function on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void) {}
on("user:login", (payload) => { // payload is { userId: string; timestamp: Date } console.log(payload.userId, payload.timestamp);});
on("user:login", (payload) => { payload.path; // compile error: 'path' doesn't exist on login payload});The event name constrains the payload type. You can’t pass the wrong shape. The handler’s parameter is automatically typed based on the event name. No runtime type checking needed.
Deeper: Generics