TypeScript Interview Questions and Answers

Last updated:

Check out 60 of the most common TypeScript interview questions, then take an AI-powered practice interview

60+
Questions
24
Basic
24
Intermediate
12
Advanced
Q1

How does TypeScript's structural type system decide whether one type is assignable to another?

BasicType System

Answer

TypeScript uses structural typing, not nominal typing: two types are compatible if their shapes are compatible, regardless of their declared names. If a function accepts { name: string }, you can pass any object that has at least a string name property, including an instance of an unrelated class. Assignability is checked member by member: the source must have every property the target requires, with compatible types.

Extra properties are generally fine, with one important exception: excess property checking. When you pass a fresh object literal directly to a typed parameter or assign it to a typed variable, the compiler flags properties that do not exist on the target type, because a literal with unknown keys is almost always a typo (think colour instead of color). Assign the same literal to an intermediate variable first and the error disappears, since the variable's inferred type legitimately has the extra property.

Interviewers probe this because it explains many everyday surprises: why two identically shaped interfaces from different libraries interoperate freely, why the empty object type {} accepts nearly everything, and why you need branded types when you want nominal behaviour, for example preventing a UserId from being passed where an OrderId is expected even though both are strings underneath. A strong answer also mentions that classes with private or protected members are the one place TypeScript behaves nominally: two classes with identical shapes but separate private fields are not assignable to each other.

interface Point { x: number; y: number }

function render(p: Point) { /* ... */ }

const withExtra = { x: 1, y: 2, z: 3 };
render(withExtra); // OK: structural typing ignores the extra z

render({ x: 1, y: 2, z: 3 });
// Error: Object literal may only specify known properties,
// and 'z' does not exist in type 'Point'. (excess property check)

class A { private secret = 1; x = 0; y = 0 }
class B { private secret = 1; x = 0; y = 0 }
let a: A = new B();
// Error: types have separate declarations of a private property,
// the one nominal corner of the type system

Key Points

  • Compatibility is decided by shape, not by declared name
  • Fresh object literals trigger excess property checking
  • private/protected members make classes behave nominally
  • Branded types are the standard workaround when you need nominal IDs
Q2

interface vs type alias: what are the real differences, and when does the choice matter?

BasicType System

Answer

For describing a plain object shape, interface and type are interchangeable, and most of the internet's 'differences' lists are outdated. The real differences: (1) Declaration merging. Two interface declarations with the same name in the same scope merge their members; type aliases with duplicate names are a compile error.

Merging is what makes module augmentation possible, for example adding fields to Express's Request or to NodeJS.ProcessEnv. (2) Expressiveness. Only type aliases can name unions, tuples, conditional types, mapped types, and template literal types; an interface can only describe object-like shapes and call/construct signatures. (3) extends vs intersection. interface B extends A produces a flattened, cached named type, while type B = A & { ... } creates an intersection that the compiler re-evaluates; on very large codebases extends is measurably cheaper for the checker and produces cleaner error messages that reference the interface name instead of dumping the expanded structure. (4) Implements. Classes can implement either, so that is not a differentiator.

Sensible 2026 team conventions: use interface for public object contracts you may want to extend or augment, use type for everything else (unions, function types, derived types). The typescript-eslint rule @typescript-eslint/consistent-type-definitions can enforce whichever direction the team picks. In interviews, mentioning declaration merging and the extends performance point signals real production experience rather than a memorised blog answer.

// Declaration merging: only interfaces do this
interface Window { myAnalytics: (event: string) => void }
// merges into the global Window type

// Unions and mapped types: only type aliases can name these
type Status = 'queued' | 'active' | 'done';
type Flags = { [K in Status]: boolean };

// extends gives cheaper checking + better errors than &
interface Base { id: string }
interface UserRow extends Base { email: string }

type AlsoUserRow = Base & { email: string }; // works, re-computed intersection

Key Points

  • Interfaces merge across declarations; type aliases cannot
  • Unions, tuples, mapped and conditional types need type aliases
  • interface extends is cached and faster than & on big codebases
  • Pick a convention and enforce it with typescript-eslint
Q3

Explain any vs unknown vs never, and why unknown should be your default for untyped data.

BasicType System

Answer

any is an escape hatch that turns off type checking in both directions: anything is assignable to any, and any is assignable to anything. Worse, it is contagious, an any flowing into an expression silently makes the result any, so one careless API response type can erase safety across a whole module. The noImplicitAny flag (part of strict) at least forces you to write any deliberately. unknown, added in TypeScript 3.0, is the type-safe counterpart: anything is assignable to unknown, but unknown is assignable to nothing (except unknown and any) until you narrow it with typeof, instanceof, a user-defined type guard, or a schema validator like Zod.

That forced narrowing is exactly what you want at trust boundaries: JSON.parse results, catch clause variables (useUnknownInCatchVariables makes this the default under strict since TS 4.4), postMessage payloads, and third-party webhook bodies. never is the bottom type: it has no values, and it is assignable to everything while nothing is assignable to it. It appears as the return type of functions that always throw, as the type of a variable after all union members have been narrowed away, and as the result of impossible intersections like string & number. Its most practical use is exhaustiveness checking: in the default branch of a switch over a discriminated union, assigning the value to never makes the compiler error when someone later adds a union member without handling it. Interviewers often close with 'when would you still use any?': legitimate answers are migration seams in a JS-to-TS conversion and rare generic plumbing where the type system cannot express the constraint, always locally, never on exported signatures.

const raw: unknown = JSON.parse(payload);

// raw.userId  -> Error: 'raw' is of type 'unknown'
if (typeof raw === 'object' && raw !== null && 'userId' in raw) {
  // narrowed enough to touch
}

function fail(msg: string): never { throw new Error(msg) }

type Shape = { kind: 'circle'; r: number } | { kind: 'square'; s: number };
function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.r ** 2;
    case 'square': return s.s * s.s;
    default: {
      const unreachable: never = s; // errors if a new kind is added
      return unreachable;
    }
  }
}
💡 Pro Tip: In code review, treat any on an exported function signature as a bug. Local any inside a migration file is tolerable; any that leaks into consumers is not.
Q4

What does "strict": true in tsconfig.json actually turn on, and which useful checks does it NOT include?

BasicCompiler Configuration

Answer

strict is an umbrella flag that enables a family of checks, currently: noImplicitAny (unannotated values that would infer any become errors), strictNullChecks (null and undefined are no longer assignable to every type), strictFunctionTypes (function parameters are checked contravariantly), strictBindCallApply (bind/call/apply are typed against the real signature), strictPropertyInitialization (class fields must be initialised or definitely assigned), noImplicitThis (untyped this is an error), useUnknownInCatchVariables (catch variables are unknown instead of any), and alwaysStrict (emits 'use strict' and parses in strict mode). New strict-family checks added in future compiler versions join the umbrella automatically, which is why library authors sometimes pin individual flags instead. Just as important is what strict does NOT include, because interviewers love this follow-up: noUncheckedIndexedAccess (indexing into arrays and index signatures returns T | undefined, catching a whole class of runtime crashes), exactOptionalPropertyTypes (distinguishes a missing property from one explicitly set to undefined), noImplicitOverride, noFallthroughCasesInSwitch, and noPropertyAccessFromIndexSignature.

Serious 2026 codebases usually run strict plus noUncheckedIndexedAccess at minimum. For migrations, the practical order is: enable strict on day one for new files if tooling allows, otherwise turn on noImplicitAny first (it surfaces the cheapest wins), then strictNullChecks (the most invasive, it typically touches every file that queries a database or a Map), then the rest, which are comparatively small. Turning strict on late is so painful that greenfield projects should never start without it.

// tsconfig.json (2026 baseline for an app)
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  }
}

Key Points

  • strict = noImplicitAny + strictNullChecks + strictFunctionTypes + 5 more
  • noUncheckedIndexedAccess and exactOptionalPropertyTypes are NOT included
  • Migration order: noImplicitAny first, strictNullChecks second
  • New strict checks join the umbrella automatically in newer compilers
Q5

How does narrowing work in TypeScript, and where does control-flow analysis lose your narrowed type?

BasicNarrowing

Answer

Narrowing is the compiler refining a union type to something more specific based on runtime checks it can understand. The recognised guards: typeof x === 'string' (with the classic gotcha that typeof null === 'object', so a null check must come separately), instanceof for class instances, the in operator for property presence, equality checks against literals (x === 'done'), truthiness checks (which also strip null and undefined), Array.isArray, discriminant property checks on tagged unions, and user-defined type guards. Control-flow analysis then tracks these facts through if/else, switch, early returns, and even assignments, so the same variable can have different types on different lines.

The interview-worthy part is where narrowing is lost. First, callbacks: if you narrow a mutable variable and then reference it inside a closure that runs later (setTimeout, array callbacks), the compiler resets the narrowing because the variable could be reassigned before the callback fires; capturing it in a const first fixes this. Second, object properties: narrowing obj.kind does narrow obj if kind is a discriminant, but narrowing does not survive through method calls that might mutate the object.

Third, destructuring before checking: once you pull kind and value out of a tagged union into separate variables, older compilers lose the link; since TS 4.6 destructured discriminants do narrow, but only when the union is written as a proper discriminated union and the variables are const. Fourth, indirect checks: hiding the typeof inside a helper function loses narrowing unless the helper is typed as a type predicate (TS 5.5 began inferring predicates for simple cases automatically). Knowing these edges is the difference between fighting the compiler and steering it.

function fmt(v: string | number | null) {
  if (v === null) return 'none';      // null stripped
  if (typeof v === 'string') return v.toUpperCase();
  return v.toFixed(2);                 // v: number here
}

function later(v: string | null) {
  if (v !== null) {
    setTimeout(() => {
      // v is string | null again inside the closure (reassignable let/param)
    }, 100);
    const fixed = v;        // const capture preserves the narrowing
    setTimeout(() => fixed.length, 100); // OK
  }
}

Key Points

  • typeof, instanceof, in, equality, truthiness, Array.isArray all narrow
  • typeof null === 'object': check null explicitly
  • Closures over mutable variables reset narrowing; capture in a const
  • Helpers only narrow if typed as type predicates (or inferred, TS 5.5+)
Q6

What can you do with union and intersection types, and what surprises do they hide?

BasicType System

Answer

A union A | B means a value is one of the members; an intersection A & B means it satisfies all members simultaneously. On a union, you can only access members common to every constituent until you narrow: given string | number, .toString() is available but .toUpperCase() is not. This is the compiler being honest, it does not know which branch you hold.

Unions of object types power the discriminated union pattern that dominates real TypeScript codebases. Intersections are how you compose object shapes without inheritance: type AuditedRow = Row & { updatedBy: string }. The surprises interviewers fish for: intersecting incompatible primitives collapses to never (string & number has no possible value), and intersecting object types with a same-named property intersects that property's type, which can quietly produce a never property that only errors when someone tries to construct the object.

Function types in unions become hard to call: ((a: string) => void) | ((a: number) => void) can only be invoked with string & number, i.e. never, because the compiler must pick an argument valid for both. Also worth knowing: unions distribute over conditional types (covered in the advanced section), large unions have real compile-time cost (a union of thousands of string literals can slow the checker noticeably), and the compiler caps union size at 100,000 members with the error 'Expression produces a union type that is too complex to represent', which people actually hit when crossing several template literal types. For modelling, prefer unions for 'one of N states' and intersections for 'mixin-style composition', and resist modelling state with optional booleans when a union would make illegal states unrepresentable.

type Ok = { ok: true; data: string };
type Err = { ok: false; error: string };
type Result = Ok | Err;

function unwrap(r: Result) {
  // r.data  -> Error: 'data' is not common to both members
  if (r.ok) return r.data;   // narrowed to Ok
  throw new Error(r.error);  // narrowed to Err
}

type Impossible = string & number;         // never
type Conflict = { v: string } & { v: number }; // v: never, errors on construction
Q7

What does 'as const' do, and how does literal type widening work without it?

BasicType System

Answer

TypeScript infers the narrowest literal type it can, then widens it depending on mutability. const s = 'GET' infers the literal type 'GET' because the binding can never change; let s = 'GET' widens to string because the variable could be reassigned. Object properties widen too: { method: 'GET' } infers { method: string }, since the property is mutable. This widening is why passing a config object literal to a function expecting method: 'GET' | 'POST' sometimes errors after you extract the object into a variable: the extracted variable widened to string. as const applies a const assertion to an entire expression: every property becomes readonly, arrays become readonly tuples of literal types, and no widening happens anywhere in the structure.

It is the idiomatic way to build enum-like constant objects (const ROLES = ['admin', 'editor'] as const, then type Role = typeof ROLES[number]), to keep Redux-style action type strings narrow, and to make tuple returns from helper functions keep their positional types ([state, setState] as const in a custom React hook, so destructuring gives the right types instead of a widened array union). Two related notes worth volunteering in an interview: TypeScript 5.0 added const type parameters (declare function define<const T>(x: T): T), which give callers as const inference without them having to write it; and satisfies (TS 4.9) is often the better tool when you want literal inference AND a shape check at the same time, because as const alone validates nothing against an expected type.

let m1 = 'GET';                 // string (widened)
const m2 = 'GET';               // 'GET'

const cfg = { method: 'GET' };  // { method: string }
const cfgC = { method: 'GET' } as const; // { readonly method: 'GET' }

const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = typeof ROLES[number]; // 'admin' | 'editor' | 'viewer'

function useToggle(init: boolean) {
  let on = init;
  const toggle = () => { on = !on };
  return [on, toggle] as const;  // readonly [boolean, () => void]
}
💡 Pro Tip: If you write 'as const' and then also need the object checked against an interface, switch to 'satisfies': it narrows AND validates.
Q8

Numeric enums, string enums, const enums: how do they differ, and why do many teams ban enums entirely?

BasicEnums

Answer

Enums are one of the few TypeScript features that emit runtime code. A numeric enum compiles to an object with a reverse mapping: enum Direction { Up } produces Direction.Up === 0 and Direction[0] === 'Up', which doubles the keys and means Object.keys iterates both directions, a classic bug source. Numeric enums are also unsound in an ugly way: for years any number was assignable to a numeric enum type; modern compilers restrict this, but out-of-range values can still sneak in through arithmetic.

String enums drop the reverse mapping and are opaque: you must write Direction.Up, not the raw string, which some teams like for refactoring and others hate for ergonomics (test files fill up with imports just to pass a value). const enums inline their values at use sites and emit nothing, but they break under isolatedModules and single-file transpilers (esbuild, swc, Babel, Vite's dev transform) because those tools compile one file at a time and cannot see the enum's values, which is why libraries must never export them. The 2026 pressure against enums has a concrete new reason: Node's type stripping (running .ts directly) only supports erasable syntax, and enums are not erasable. TypeScript 5.8 added the erasableSyntaxOnly flag that errors on enums, namespaces with values, and parameter properties precisely so codebases stay strippable. The standard replacement is a plain object with as const plus a derived union type: you keep autocomplete, get a plain-JS runtime value, stay erasable, and the type side is a simple string literal union that interoperates with raw strings from JSON and databases without casts.

// Instead of: enum OrderStatus { Pending = 'PENDING', Paid = 'PAID' }
const OrderStatus = {
  Pending: 'PENDING',
  Paid: 'PAID',
  Refunded: 'REFUNDED',
} as const;

type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus];
// 'PENDING' | 'PAID' | 'REFUNDED'

function advance(s: OrderStatus) { /* ... */ }
advance('PAID');            // raw strings from an API just work
advance(OrderStatus.Paid);  // and so does the namespaced form

Key Points

  • Numeric enums emit reverse mappings; Object.keys sees both directions
  • const enums break under isolatedModules / esbuild / swc; never export them
  • Enums are not erasable syntax: they block Node type stripping
  • as const object + keyof typeof union is the modern replacement
Q9

How do tuples differ from arrays in TypeScript, and what do optional, rest, and named tuple elements add?

BasicType System

Answer

An array type T[] says 'any number of T'; a tuple type like [string, number] fixes both the length and the type at each position. Tuples are how you type fixed-shape returns: a [value, error] pair, a [state, setter] hook return, Object.entries-style pairs, or coordinates. Indexing past a tuple's length is a compile error, and destructuring gives each variable its positional type instead of a union.

Modern tuple syntax adds three tools. Optional elements ([string, number?]) allow the tail to be omitted, changing length to 1 | 2. Rest elements ([string, ...number[]]) type variadic shapes, and since TypeScript 4.0's variadic tuple types the rest can sit in the middle ([boolean, ...string[], number]), which is what makes it possible to type functions that manipulate argument lists generically.

Named tuple elements ([id: string, count: number]) are purely documentation: they change nothing about assignability, but they make signatures and editor tooltips readable, and the compiler enforces that you name all elements or none. Combine tuples with readonly for immutability: readonly [number, number] rejects push and index assignment, and as const infers readonly tuples of literals automatically. One gotcha worth stating: a plain array literal infers as an array, not a tuple ([1, 'a'] infers (string | number)[]), so returns that should be tuples need as const, an explicit annotation, or a tuple-returning helper. Interviewers also like the interplay with rest parameters: function fn(...args: [string, number]) gives you a fully typed argument list, and spreading a tuple into a call checks arity, which plain arrays cannot do.

type Entry = [key: string, value: number];

function parsePair(s: string): [value: number, error: string | null] {
  const n = Number(s);
  return Number.isNaN(n) ? [0, 'not a number'] : [n, null];
}

const [val, err] = parsePair('42'); // val: number, err: string | null

const pair = [1, 'a'];              // (string | number)[] , NOT a tuple
const realPair = [1, 'a'] as const; // readonly [1, 'a']

// Variadic tuples (TS 4.0+): typing a partial application helper
function partial<A, R extends unknown[], Out>(
  fn: (a: A, ...rest: R) => Out, a: A,
) {
  return (...rest: R) => fn(a, ...rest);
}
Q10

When are the non-null assertion operator (!) and definite assignment assertions acceptable, and what should you use instead?

BasicNull Safety

Answer

The postfix ! tells the compiler 'trust me, this is not null or undefined' and removes null | undefined from the type with zero runtime effect. It is a lie waiting to be exposed: if the value ever is null, you get the exact TypeError that strictNullChecks existed to prevent, except now the type system actively hid it. Legitimate uses are narrow: values you initialised earlier in a lifecycle the compiler cannot follow (a beforeEach in Jest assigning a variable used in tests), Map.get immediately after a has check in hot code, and DOM lookups where the element is statically guaranteed by the template you own.

Even then, better options usually exist: restructure so initialisation happens in the declaration, use a small assertNonNull helper typed with asserts val is NonNullable<T> that throws a descriptive error at the boundary, or use optional chaining with an explicit fallback. The related definite assignment assertion (declare name!: string on a class field) silences strictPropertyInitialization when a field is assigned outside the constructor, typical in dependency-injection frameworks like Angular and NestJS where the container populates fields, and in ORMs like TypeORM where entity fields are hydrated by the library. That is its one honest use case.

In code review, a good heuristic: every ! should be explainable in one sentence referencing an invariant the compiler cannot see; if the sentence is 'it should never be null', replace it with a runtime check. Teams that let ! spread end up with strictNullChecks providing a false sense of security, and the typescript-eslint rule no-non-null-assertion exists to keep the operator visible in review.

function assertPresent<T>(v: T, msg: string): asserts v is NonNullable<T> {
  if (v === null || v === undefined) throw new Error(msg);
}

const user = users.get(id);
assertPresent(user, `user ${id} missing after auth`); // throws with context
user.email; // narrowed to non-null, no ! needed

class OrderEntity {
  id!: string; // definite assignment: hydrated by the ORM, not the constructor
}
💡 Pro Tip: grep your codebase for '!.' during interview prep: being able to say how many you removed and how is a strong production-experience signal.
Q11

What is the difference between void, undefined, and never as return types?

BasicFunctions

Answer

undefined as a return type means the function literally returns the value undefined and callers can use it as such. never means the function does not return at all: it always throws or loops forever, and code after a call to it is unreachable, which the compiler exploits for narrowing. void is the interesting one because it means 'the return value must not be observed', and it behaves specially in one place: a function TYPE with a void return accepts implementations that return anything. That is deliberate. Array.prototype.forEach declares its callback as returning void, and people constantly pass arrow functions with implicit returns like arr.forEach(x => list.push(x)), where push returns a number.

If void were strict about this, half the callbacks in the ecosystem would error. The compiler allows the value to be returned but types the result of calling through the void signature as void, so you cannot consume it. This is also the root of a classic production bug the interviewer may be fishing for: passing an async function where a void-returning callback is expected compiles fine, but the returned promise is floating, unawaited and swallowing rejections.

The typescript-eslint rules no-misused-promises and no-floating-promises exist precisely for this. Contrast with a function DECLARATION typed void: there, returning a value is an error. Final nuance: in a union-heavy codebase, prefer never for exhaustiveness helpers (assertNever) and avoid annotating undefined returns explicitly, letting inference produce T | undefined where it is real.

declare function runTask(cb: () => void): void;

runTask(() => 42);            // OK: value returned into void is ignored

runTask(async () => {          // compiles, but the Promise is floating:
  await api.delete('/session'); // a rejection here is silently lost
});
// @typescript-eslint/no-misused-promises flags this

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

Key Points

  • void = 'do not observe the return'; declarations may not return values, but callbacks may
  • Async callbacks in void positions create floating promises
  • never marks non-returning functions and powers exhaustiveness checks
  • no-floating-promises / no-misused-promises are the guardrails
Q12

Type assertions with 'as' vs the 'satisfies' operator: when is each the right tool?

BasicType System

Answer

An as assertion overrides the compiler's judgement in one direction: it will let you assert between types that overlap, silently discarding checks. The two failure modes are asserting wrongly today (const n = value as string when value is a number at runtime) and the assertion rotting silently tomorrow: if the underlying type changes, the as keeps compiling while hiding the mismatch. Double assertions (x as unknown as T) can force absolutely anything and should be treated as a red flag in review. satisfies, added in TypeScript 4.9, solves the opposite problem: you want the compiler to CHECK an expression against a type without WIDENING the expression to that type.

The classic example is a config or route map: annotate it as Record<string, Route> and every lookup loses the specific keys; leave it unannotated and typos in the values go uncaught. With satisfies Record<string, Route>, the values are validated, excess or malformed entries error, and the inferred type keeps the literal keys and per-key value types, so config.home autocompletes and config.hoem errors. It also pairs beautifully with as const.

Practical rules for interviews: use a declared type on public function signatures, use satisfies for constant maps and configs where you want validation plus narrow inference, and reserve as for the boundary cases where you genuinely know more than the compiler, such as JSON you have just validated by hand, or the result of document.getElementById where you own the markup. If you find yourself writing as to silence an error you do not understand, that is the moment to stop and read the error properly.

type Route = { path: string; auth: boolean };

const routes = {
  home:  { path: '/',        auth: false },
  admin: { path: '/admin',   auth: true },
} satisfies Record<string, Route>;

routes.admin.auth;   // OK, keys preserved with full autocomplete
// routes.billing    -> compile error: no such key

const annotated: Record<string, Route> = routes;
// annotated.admin exists at runtime, but the type lost all key info

const el = document.getElementById('chart') as HTMLCanvasElement | null;

Key Points

  • as overrides checking; satisfies validates without widening
  • satisfies keeps literal keys: ideal for config/route/theme maps
  • x as unknown as T bypasses everything, treat as a review red flag
  • Declared types for public signatures, satisfies for constants
Q13

How does TypeScript's type inference and contextual typing work, and where should you still write explicit annotations?

BasicType System

Answer

Inference flows in two directions. Bottom-up: the compiler infers types from initialisers (const n = 1 + 2 is number, return expressions determine return types, array literals infer a best common type of their elements). Top-down, called contextual typing: the expected type of a position types the expression in it, which is why the parameter s in arr.map(s => s.length) needs no annotation when arr is string[], why event handler parameters in React are typed from the prop, and why object literals passed as arguments get excess property checking.

Contextual typing is also what makes callback-heavy APIs pleasant, and its limits explain common annoyances: an arrow function assigned to an untyped variable first loses the context (annotate the variable with the function type to restore it). Where explicit annotations still earn their place: exported function signatures (return type annotations on public APIs prevent accidental breaking changes when an implementation edit changes the inferred return, and they make declaration emit faster and stabler, which isolatedDeclarations in TS 5.5 formalises), empty containers (const items = [] infers never[] under strict, so write const items: string[] = []), values built up across branches where inference would produce a too-narrow or too-wide union, and recursive functions where inference cannot bottom out. Conversely, do not annotate what inference already gets right: redundant annotations on locals add noise, drift from reality, and hide genuinely informative annotations in the clutter. A good closing line in an interview: inference is the default, annotations are for module boundaries, empty starts, and places where you want the compiler to hold a contract stable over time.

const names = ['asha', 'ravi'];
names.map(n => n.toUpperCase()); // n: string via contextual typing

const handler = (e) => e.clientX;     // e: any (no context!)
const typed: (e: MouseEvent) => number = e => e.clientX; // context restored

const acc = [];        // never[] under strict
acc.push('x');         // Error: string not assignable to never
const acc2: string[] = []; // correct fix

// Public API: annotate the return so refactors cannot silently change it
export function toPaise(rupees: number): number {
  return Math.round(rupees * 100);
}
Q14

What do the keyof and typeof type operators do, and how do they combine in everyday code?

BasicType Operators

Answer

keyof T produces a union of T's property keys as literal types: keyof { id: number; name: string } is 'id' | 'name'. On index signatures it yields the index type (string | number for a string index, because JavaScript coerces numeric keys). typeof, in TYPE position, captures the type of a VALUE: given const config = {...}, typeof config is the inferred type of that object, no manual interface duplication needed. This is different from JavaScript's runtime typeof, which returns one of a few strings at runtime; same keyword, two worlds.

The combination keyof typeof someObject is the everyday workhorse: it turns a real runtime object into a union of its keys, powering the enum-replacement pattern, lookup-table typing, and safe dynamic access. From there you build getProperty<T, K extends keyof T>(obj: T, key: K): T[K], the canonical generic accessor where T[K] is an indexed access type reading 'the type of that property'. Indexed access composes further: typeof config['retries'] extracts one property's type, and typeof arr[number] extracts an array's element type, an idiom you will see constantly in codebases that derive types from as const data instead of writing them twice.

Two details worth volunteering: keyof of a union is the INTERSECTION of the members' keys (only shared keys are safe to read), while keyof of an intersection is the union of keys; and under keyof, optional and readonly modifiers do not appear, they belong to the property, not the key. Interviewers use this question to check whether you derive types from single sources of truth or maintain parallel hand-written interfaces that drift.

const httpErrors = {
  400: 'Bad Request',
  401: 'Unauthorized',
  404: 'Not Found',
} as const;

type KnownStatus = keyof typeof httpErrors;      // 400 | 401 | 404
type ErrorText  = typeof httpErrors[KnownStatus]; // union of the messages

function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const cfg = { host: 'api.goodspace.ai', retries: 3 };
const r = getProp(cfg, 'retries'); // number
// getProp(cfg, 'port')            -> Error: 'port' not in keyof typeof cfg
Q15

How does strictNullChecks change the type system, and what are the idiomatic patterns for handling null and undefined?

BasicNull Safety

Answer

Without strictNullChecks, null and undefined are members of every type, which recreates the exact billion-dollar-mistake semantics TypeScript was supposed to fix: user.email compiles even when user came back null from a lookup. With the flag on (it is part of strict), null and undefined are distinct types, and anything nullable must say so: string | null, or T | undefined for things that may be absent. The compiler then forces a narrowing step before use, and the whole family of idioms follows.

Optional chaining (a?.b?.c) short-circuits to undefined and its result type includes undefined. Nullish coalescing (x ?? fallback) substitutes only for null/undefined, unlike || which also swallows 0, '' and false, a distinction that has caused real bugs in pricing and pagination code. Map.get returns V | undefined, array find returns T | undefined, and under noUncheckedIndexedAccess even arr[i] returns T | undefined, each forcing an explicit decision: check, default, or throw.

The NonNullable<T> utility strips null and undefined at the type level. For 'should never happen' cases, prefer a throwing assertion helper over the ! operator so failures carry context. A subtlety interviewers enjoy: optional property (name?: string) versus explicit union (name: string | undefined) are different under exactOptionalPropertyTypes; the first may be omitted entirely, the second must be present even if undefined, which matters when spreading objects into database updates where 'absent' means 'do not touch' and 'undefined' means 'clear the column'. Codebases that model that distinction correctly avoid a whole class of accidental data wipes.

type User = { name: string; referrer?: string };

function display(u: User | null): string {
  const pageSize = Number(process.env.PAGE_SIZE) || 20; // BUG: 0 becomes 20
  const safeSize = Number(process.env.PAGE_SIZE) ?? 20; // only null/undefined

  return u?.name ?? 'guest';
}

const found = users.find(u => u.name === 'asha'); // User | undefined
if (!found) throw new Error('asha not seeded in test fixture');
found.name; // narrowed

Key Points

  • Nullability becomes explicit: T | null / T | undefined
  • ?? only replaces null/undefined; || also swallows 0, '' and false
  • name?: string and name: string | undefined differ under exactOptionalPropertyTypes
  • Prefer throwing assertion helpers over the ! operator
Q16

Write a generic function from scratch: how do type parameters get inferred, and when do you specify them explicitly?

BasicGenerics

Answer

A generic function declares type parameters in angle brackets and uses them to link inputs to outputs: function first<T>(arr: T[]): T | undefined. The power is the LINK, not the letter: identity<T>(x: T): T tells the compiler the output is exactly the input's type, so const s = identity('hi') infers T = 'hi' without any annotation. Inference works by matching argument types against parameter positions; when a type parameter appears in several positions, the compiler tries to find a single consistent assignment, and mismatches surface as errors at the call site.

You specify type arguments explicitly in three situations: when there is nothing to infer from (const cache = createCache<UserProfile>(), where T only appears in the return type), when inference picks something narrower or wider than intended (fetchList<Order>('/orders') on a wrapper whose body returns any from JSON), and when you want the call site to document intent. Common beginner mistakes that interviewers watch for: writing <T> and then never using T in a way that links anything (a generic that could just be unknown), over-constraining with unnecessary extends, and the classic misconception that generics exist at runtime, they are erased completely, so you cannot do new T() or typeof T. If a function needs to construct T, take a constructor parameter typed (new () => T).

Also know the arrow-function syntax pitfall in .tsx files: <T>(x: T) => x parses as JSX, so you write <T,>(x: T) => x or use a function declaration. Finally, keep generics minimal: each type parameter should appear at least twice in the signature (input and output, or two inputs); single-use type parameters are a lint-worthy smell (typescript-eslint's no-unnecessary-type-parameters).

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(i => i[key]);
}

const orders = [
  { id: 'o1', amountPaise: 49900 },
  { id: 'o2', amountPaise: 99900 },
];
const amounts = pluck(orders, 'amountPaise'); // number[], fully inferred

// Explicit type argument: nothing to infer from
function createStore<T>() {
  const items: T[] = [];
  return { add: (t: T) => items.push(t), all: () => items };
}
const store = createStore<{ id: string }>();

// Runtime construction needs a constructor value, generics are erased
function make<T>(Ctor: new () => T): T { return new Ctor(); }
Q17

What do TypeScript classes add over ES2022 classes: access modifiers, parameter properties, abstract members, and implements?

BasicClasses

Answer

TypeScript layers compile-time constructs over standard JavaScript classes. Access modifiers: public (default), private, and protected restrict where members can be referenced, but only at compile time, the emitted JavaScript has ordinary properties, and a cast or bracket access can defeat them. Contrast with ECMAScript #private fields, which are enforced at runtime by the engine and are hard-private even against JSON.stringify and Object.keys; modern codebases prefer #private for true encapsulation and TypeScript private for documentation-grade privacy. readonly on a class field permits assignment only in the declaration or constructor.

Parameter properties are the shorthand where constructor(private readonly repo: UserRepo) declares AND assigns the field in one stroke; NestJS and Angular dependency injection lean on this heavily, though note it is non-erasable syntax and errors under TS 5.8's erasableSyntaxOnly flag, so Node-type-stripping codebases spell fields out longhand. abstract classes cannot be instantiated and may declare abstract members that concrete subclasses must implement; unlike interfaces they can also carry implemented methods and constructor logic, making them the tool for template-method patterns. implements checks that a class satisfies an interface's shape but copies nothing: you still write every member, and a common surprise is that implements does not make the class's method PARAMETERS adopt the interface's types, they infer as any without annotations (noImplicitAny catches this). strictPropertyInitialization forces fields to be initialised in the declaration, the constructor, or with a definite-assignment assertion. Finally, class expressions and static blocks work as in modern JavaScript, and useDefineForClassFields aligns TypeScript's field emit with the ECMAScript spec semantics, which changed subtle initialisation-order behaviour when it became the default with ES2022 targets.

interface Clock { now(): Date }

abstract class BaseJob {
  constructor(protected readonly clock: Clock) {} // parameter property
  abstract run(): Promise<void>;
  protected log(msg: string) {
    console.log(`[${this.clock.now().toISOString()}] ${msg}`);
  }
}

class ReindexJob extends BaseJob implements Clock {
  #attempts = 0;              // ES #private: enforced at runtime
  now() { return new Date(); } // implements checks the shape
  async run() {
    this.#attempts++;
    this.log(`attempt ${this.#attempts}`);
  }
}

Key Points

  • TS private is compile-time only; #private is runtime-enforced
  • Parameter properties are non-erasable: blocked by erasableSyntaxOnly
  • abstract classes carry shared logic; interfaces cannot
  • implements validates shape but does not type your parameters for you
Q18

What are .d.ts declaration files, how do @types packages work, and when do you write your own?

BasicTooling

Answer

A .d.ts file contains only type declarations, no implementations: it tells the compiler the shape of code that exists elsewhere. Three places you meet them. First, published libraries: a package written in TypeScript ships generated .d.ts files next to its JavaScript, referenced by the types (or exports map typesVersions/types conditions) field in package.json, and consumers get full typing automatically.

Second, DefinitelyTyped: for JavaScript libraries that ship no types, the community maintains @types/* packages (npm i -D @types/lodash), which the compiler picks up automatically from node_modules/@types. If both exist, the library's own bundled types win. Third, your own ambient declarations: a global.d.ts included in the project can declare module 'legacy-analytics' to type an untyped internal package, declare global augmentations like typing process.env keys via the NodeJS.ProcessEnv interface, or tell the compiler about non-code imports (declare module '*.svg').

Practical mechanics worth knowing: declaration files participate in compilation via the include globs or the files array; skipLibCheck: true skips type-checking INSIDE all .d.ts files, which is standard practice in 2026 because checking every dependency's declarations is slow and their conflicts are rarely yours to fix; and tsc --declaration emits .d.ts for your own build, which is how you publish a typed library. You write declarations by hand mainly for untyped third-party scripts, global variables injected by other bundles (window.__INITIAL_STATE__), and module augmentation. A candidate who can explain the resolution order (bundled types, then @types, then ambient declarations) and knows that an out-of-date @types package version mismatch is a routine cause of phantom errors comes across as someone who has actually unblocked a team.

// types/global.d.ts
declare module '*.svg' {
  const url: string;
  export default url;
}

declare global {
  interface Window { __INITIAL_STATE__?: unknown }
  namespace NodeJS {
    interface ProcessEnv {
      DATABASE_URL: string;
      RAZORPAY_KEY_ID: string;
      NODE_ENV: 'development' | 'production' | 'test';
    }
  }
}

export {}; // keep this file a module so 'declare global' works
💡 Pro Tip: If an import shows 'Could not find a declaration file for module X', check for @types/X first; write a one-line declare module only as a last resort and file a ticket to type it properly.
Q19

What do import type / export type do, and why does verbatimModuleSyntax make them mandatory?

BasicModules

Answer

Because types are erased at compile time, an import that only brings in types must vanish from the emitted JavaScript. Historically the compiler decided this itself through import elision: it analysed whether each imported name was used as a value and dropped type-only imports. That worked for whole-program tsc, but modern pipelines transpile one file at a time (esbuild, swc, Babel, Vite, and Node's own type stripping), and a single-file transpiler cannot know whether an imported name is a type or a value defined in another file.

Guessing wrong either leaves a dead import (which can crash if the module does not exist at runtime, or worse, execute side effects) or drops a needed one. import type { User } from './models' and export type { User } mark the intent explicitly: these statements are guaranteed to be fully erased. The inline form import { type User, api } from './sdk' mixes both in one statement. TypeScript 5.0 replaced the older isolatedModules-adjacent flags (importsNotUsedAsValues, preserveValueImports) with verbatimModuleSyntax, which has a brutally simple rule: imports and exports are emitted exactly as written, except type-marked ones, which are removed.

Under this flag, using a type-only import without the type keyword becomes an error, so the codebase stays safe for single-file transpilation by construction. Two real-world consequences: side-effectful modules must be imported bare (import './polyfill') so nothing can elide them, and circular type dependencies between modules become harmless because type-only edges disappear entirely from the runtime graph, a trick teams use deliberately to break value-level import cycles. The eslint auto-fix consistent-type-imports migrates existing codebases mechanically.

// user-service.ts
import type { User } from './models';       // erased entirely from JS output
import { type AuditEvent, logAudit } from './audit'; // mixed inline form

export type { User };                        // type-only re-export

export function suspend(u: User): void {
  logAudit({ kind: 'suspend', userId: u.id } satisfies AuditEvent);
}

// tsconfig.json
// { "compilerOptions": { "verbatimModuleSyntax": true } }
// Now: import { User } from './models' without 'type' -> compile error

Key Points

  • Type-only imports must be erasable for single-file transpilers
  • verbatimModuleSyntax (TS 5.0) emits imports verbatim, erasing only 'type' ones
  • Replaces importsNotUsedAsValues / preserveValueImports
  • Type-only edges break runtime circular-import problems
Q20

TypeScript types are erased at compile time. What concretely does that mean at runtime, and what mistakes does it cause?

BasicFundamentals

Answer

After compilation, no trace of the type system remains: interfaces, type aliases, generics, annotations, satisfies, and as all vanish, leaving plain JavaScript. Only a handful of TypeScript features emit real code: enums, namespaces with runtime values, parameter properties, and legacy experimental decorators, exactly the list that the erasableSyntaxOnly flag bans. Erasure has direct practical consequences that interviewers test through bug-shaped questions.

You cannot check value instanceof SomeInterface: interfaces do not exist at runtime, so the check is a compile error; the runtime tools are typeof, instanceof against classes, property probes with in, and hand-written or generated type guards. You cannot dispatch on a generic: function parse<T>() cannot branch on T, because T is gone; pass a discriminant value or a schema instead. Above all, types do not validate data: response as User is a promise to the compiler, not a check, so an API that starts returning snake_case fields will sail through the type system and explode somewhere far from the fetch call.

The boundary rule follows: every ingress point (HTTP responses, request bodies, queue messages, localStorage, environment variables) should be treated as unknown and validated at runtime with a schema library like Zod, whose z.infer derives the static type from the same schema, keeping runtime and compile time in lockstep. Erasure is also why TypeScript adds zero runtime performance cost and why source maps plus tsc --noEmit CI checks are the norm: the type layer is a static analysis pass, and treating it as anything more, especially as a security or validation mechanism, is the misunderstanding behind a large share of production TypeScript incidents.

interface User { id: string; email: string }

// if (value instanceof User) {}  -> Error: 'User' only refers to a type

// The dangerous pattern: a compile-time promise, zero runtime checking
const u1 = (await res.json()) as User;

// The safe pattern: validate at the boundary, infer the static type
import { z } from 'zod';
const UserSchema = z.object({ id: z.string(), email: z.string().email() });
type UserT = z.infer<typeof UserSchema>;
const u2: UserT = UserSchema.parse(await res.json()); // throws on drift

Key Points

  • Interfaces, generics, and annotations do not exist at runtime
  • instanceof works on classes only; use guards for shapes
  • 'as User' validates nothing: schema-validate at every ingress point
  • Only enums, namespaces, parameter properties, and old decorators emit code
Q21

Walk through the tsconfig options that matter most: target, module, lib, moduleResolution, and noEmit. What breaks when they are wrong?

BasicCompiler Configuration

Answer

target sets the JavaScript version of the OUTPUT: with target es2017 the compiler downlevels newer syntax (optional chaining becomes a chain of ternaries under older targets) and, importantly, changes class-field semantics via useDefineForClassFields defaulting on for es2022 and later. In 2026, apps running on evergreen browsers or current Node LTS use es2022 or later; setting es5 out of habit bloats output and slows builds for no one's benefit. lib declares which built-in APIs the checker believes exist: target implies a default lib, but you override it to add DOM for browser code or drop it for pure Node services (a backend accidentally compiling with DOM lib will happily let you reference document and fetch typings you may not have). module controls the emitted module system: esnext or preserve for bundler-consumed code, nodenext for code Node runs directly, commonjs for legacy. moduleResolution controls how import specifiers are FOUND: bundler (TS 5.0) matches how Vite/esbuild/webpack resolve, allowing extensionless imports and package.json exports maps; nodenext enforces Node's real ESM rules, including mandatory .js extensions on relative imports in ESM files, the single most-Googled TypeScript error of the ESM transition. The pairing rules matter: module nodenext requires moduleResolution nodenext; mixing bundler resolution with code Node executes directly produces builds that type-check and then crash with ERR_MODULE_NOT_FOUND. noEmit: true turns tsc into a pure type checker while esbuild/swc/Vite produce the actual JavaScript, the dominant 2026 setup: fast transpilers do emit, tsc does correctness in CI. Honourable mentions: esModuleInterop and allowSyntheticDefaultImports fix default-import friction with CommonJS packages, sourceMap feeds debuggers, and incremental with tsBuildInfoFile makes repeat checks fast.

// Backend service that Node runs directly (ESM)
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["es2022"],            // no DOM in a server
    "outDir": "dist",
    "strict": true,
    "sourceMap": true
  }
}
// import { helper } from './helper.js'  <- .js extension REQUIRED here,
// even though the source file is helper.ts

// Frontend checked by tsc, built by Vite
// { "module": "esnext", "moduleResolution": "bundler", "noEmit": true }
Q22

How do function overloads work in TypeScript, and what are their sharp edges compared to union parameters?

BasicFunctions

Answer

Overloads let one function present multiple call signatures: you write two or more bare signature declarations followed by a single implementation whose signature must be general enough to cover all of them. Callers see ONLY the overload list, never the implementation signature, which is the first sharp edge: an implementation typed (x: string | number) does not become a callable signature; if no overload matches, the call errors even though the implementation could handle it. Resolution picks the FIRST matching overload in declaration order, so ordering matters: put more specific signatures first, or a general one will shadow them.

Second sharp edge: the implementation body is checked loosely against the overloads, the compiler verifies the implementation signature is compatible with each overload but does not verify that your branching logic actually returns the right type per overload, so it is possible to write an overloaded function that lies (declared to return string for string input while actually returning a number), a known unsoundness. Third: overloads do not compose with higher-order usage well, passing an overloaded function to map picks one signature, usually the last, and conditional-type utilities like Parameters<T> see only the LAST overload. When to prefer alternatives: if the return type does not depend on the input type, a union parameter is simpler and safer; if the return type varies mechanically with the input, a generic with a conditional type often expresses it in one signature; overloads win when the relationships are irregular (createElement('a') returning HTMLAnchorElement, 'canvas' returning HTMLCanvasElement in lib.dom.ts is the canonical real-world example, along with Node's fs.readFile changing return type based on the encoding argument).

function toDate(value: number): Date;              // epoch millis
function toDate(value: string, tz: string): Date;  // ISO + timezone
function toDate(value: string | number, tz?: string): Date {
  if (typeof value === 'number') return new Date(value);
  return parseInTimeZone(value, tz ?? 'Asia/Kolkata');
}

toDate(1735689600000);          // OK, first overload
toDate('2026-08-11', 'Asia/Kolkata'); // OK, second
// toDate('2026-08-11')         -> Error: no overload expects 1 string arg

type P = Parameters<typeof toDate>; // [string, string] , LAST overload only

Key Points

  • Callers see the overload list, never the implementation signature
  • First matching overload wins: order specific to general
  • Parameters / ReturnType utilities see only the last overload
  • Prefer unions or generics when relationships are regular
Q23

What does readonly actually guarantee in TypeScript, and where does the immutability story leak?

BasicType System

Answer

readonly on a property forbids assignment through THAT type after construction; ReadonlyArray<T> (or readonly T[]) removes the mutating surface: push, pop, splice, sort, and index assignment all become errors, and readonly [A, B] does the same for tuples. Readonly<T> maps readonly across one level of an object. All of it is compile-time only: the emitted objects are perfectly mutable JavaScript, so readonly protects against your OWN code's mistakes, not against runtime mutation by untyped code.

The leaks are what interviewers want to hear. First, shallowness: Readonly<{ items: string[] }> protects the items binding but the array inside is fully mutable; a recursive DeepReadonly must be built with mapped types or taken from a library. Second, aliasing: a readonly T[] can be the SAME array as someone else's mutable T[]; readonly restricts what you can do through this reference, not what the array is.

Assignability is deliberately asymmetric: mutable arrays are assignable to readonly array types (safe: it only removes capabilities) but not the reverse, so a function that accepts readonly T[] accepts everything, which is why parameters should default to readonly, it costs callers nothing and documents non-mutation. Third, method escape hatches: pre-ES2023 sort and reverse mutate in place, and the newer non-mutating toSorted, toReversed, and toSpliced (available with lib es2023) are the readonly-friendly replacements. Fourth, Object.freeze does give runtime shallow immutability and its typing returns Readonly<T>, the one place compile-time and runtime immutability meet. For deep, structural, runtime-guaranteed immutability, teams reach for libraries like Immer, where the produce API lets you write mutable-looking updates that emit frozen copies.

function total(amounts: readonly number[]): number {
  // amounts.push(0)   -> Error: push does not exist on readonly number[]
  return amounts.reduce((a, b) => a + b, 0);
}

const prices = [100, 250, 400];
total(prices);            // mutable -> readonly is fine

const sorted = prices.toSorted((a, b) => a - b); // ES2023, no mutation

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
💡 Pro Tip: Make readonly T[] your default for array parameters in shared code. It is a zero-cost contract, and removing it later is easy; adding it later breaks callers.
Q24

What are the ways to actually run TypeScript in 2026: tsc, tsx, ts-node, Node's type stripping, and bundlers?

BasicTooling

Answer

Knowing the execution landscape signals you have shipped, not just studied. Option one, compile then run: tsc emits JavaScript to outDir and Node runs that; still the most predictable path for published packages and Docker images, usually with tsc handling only types (noEmit) and esbuild or swc doing the actual emit because they transpile orders of magnitude faster (they skip type checking entirely, which is why CI must still run tsc --noEmit or you can merge type errors). Option two, on-the-fly runners: tsx (built on esbuild) is the de facto dev runner (npx tsx watch src/main.ts) and has largely displaced ts-node, which suffered from ESM-loader friction.

Option three, the runtime itself: recent Node versions execute .ts files directly by stripping type annotations, no type checking, no transform of non-erasable syntax, which is exactly why erasableSyntaxOnly exists: enums, namespaces, and parameter properties throw at load time under stripping, so codebases targeting this must avoid them. Deno and Bun have run TypeScript natively for years, Bun notably fast for scripts and tests. Option four, bundlers for the browser: Vite serves TypeScript through esbuild in dev and Rollup for production builds, with type checking pushed to the IDE and CI.

The unifying 2026 pattern across all of these: type checking and code execution are decoupled. Nothing that RUNS your TypeScript checks it, and the checker (tsc, soon the Go-native tsgo) never runs it. Interviewers often finish with 'so where do type errors actually stop a deploy?': the correct answer is a CI step running tsc --noEmit (or project-references build mode, tsc -b), plus editor feedback via the language server during development.

# Dev loop with tsx (esbuild under the hood)
npx tsx watch src/server.ts

# Recent Node: run .ts directly via type stripping (no enums/namespaces!)
node src/server.ts

# Production build: swc emits, tsc checks
npx swc src -d dist --strip-leading-paths
npx tsc --noEmit          # the actual gate in CI

# Vite app: dev server transpiles instantly, checking stays in CI
npm run dev
npx tsc --noEmit -p tsconfig.app.json

Key Points

  • Fast transpilers (esbuild/swc) do not type-check: CI needs tsc --noEmit
  • tsx has largely replaced ts-node for dev execution
  • Node type stripping runs erasable syntax only
  • Checking and running are fully decoupled in modern setups
Q25

Partial, Required, Pick, Omit, Record: how do these utility types behave, and what are their gotchas on real domain models?

IntermediateUtility Types

Answer

These five cover most day-to-day type derivation. Partial<T> makes every property optional, the natural type for PATCH payloads and update DTOs; its gotcha is being shallow (nested objects stay required inside) and being too permissive for updates where SOME field must be present, for which you compose Partial with a required core or use a union of allowed patch shapes. Required<T> strips optionality (and, per mapped-type rules, the -? modifier also removes undefined from the property type when it came from optionality).

Pick<T, K> selects keys, Omit<T, K> removes them; the classic Omit gotcha is that K is not constrained to keyof T (it accepts any string, so a typo like Omit<User, 'emial'> silently removes nothing), and Omit does not distribute over unions: Omit applied to a discriminated union collapses it to a single object type of common structure, destroying the discrimination; write a DistributiveOmit with a conditional type when deriving from unions. Omit also loses call signatures and index-signature subtleties, and both Pick and Omit strip modifiers only per key, not deeply. Record<K, V> builds an object type from a key union and value type; Record<string, V> is an index signature (every read yields V, or V | undefined under noUncheckedIndexedAccess), while Record<'a' | 'b', V> demands exactly those keys, a difference teams exploit for exhaustive lookup tables: Record<OrderStatus, Handler> fails to compile when a new status is added but its handler is missing, turning a runtime gap into a compile error. Under the hood, all five are one-line mapped or conditional types in lib.es5.d.ts, and being able to write Partial from scratch ({ [K in keyof T]?: T[K] }) is a standard interview checkpoint.

type User = { id: string; email: string; profile: { bio: string } };

type UserPatch = Partial<User>;      // profile itself optional, but if given,
                                     // bio is still REQUIRED (shallow!)

type Creds = Pick<User, 'id' | 'email'>;
type PublicUser = Omit<User, 'email'>;
type Oops = Omit<User, 'emial'>;     // compiles! typo silently ignored

type Status = 'created' | 'paid' | 'shipped';
const handlers: Record<Status, (id: string) => void> = {
  created: notifySeller,
  paid: capturePayment,
  shipped: sendTracking,
}; // add 'refunded' to Status -> this object errors until handled

type DistributiveOmit<T, K extends PropertyKey> =
  T extends unknown ? Omit<T, K> : never;

Key Points

  • Partial/Required are shallow; nested shapes keep their modifiers
  • Omit's key parameter is unconstrained: typos vanish silently
  • Omit/Pick collapse discriminated unions: use a distributive variant
  • Record over a literal-union key gives compile-time exhaustive tables
Q26

How do ReturnType, Parameters, Awaited, and NonNullable let you derive types from existing functions, and where do they fall short?

IntermediateUtility Types

Answer

These utilities extract types from values you already have, keeping a single source of truth. ReturnType<typeof fn> gives the declared return type, the standard way to type the result of a factory or a Redux-style store creator without exporting a parallel interface. Parameters<typeof fn> yields the parameter list as a tuple, ideal for writing wrappers that forward arguments: (...args: Parameters<typeof original>) => keeps the wrapper honest as the original evolves.

ConstructorParameters and InstanceType do the same for classes. Awaited<T> (TS 4.5) recursively unwraps promises, including nested Promise<Promise<T>> and thenables; it is what the compiler itself uses to type await and Promise.all, and Awaited<ReturnType<typeof fetchUser>> is the idiom for naming an async function's resolved value. NonNullable<T> strips null and undefined, handy after lookups.

The shortfalls are predictable once you know the mechanics. All of them are conditional types with infer inside, so on OVERLOADED functions they see only the last overload; on generic functions they instantiate with the constraint (often unknown), losing the relationship between input and output, so deriving from a generic function usually yields something uselessly wide. ReturnType cannot 'call' the function with specific argument types: for a function whose return depends on its input type, you must re-model that dependency yourself.

And deriving everything from implementation types can invert your architecture: for public APIs, an explicitly declared interface is often better than ReturnType chains, because it makes breaking changes deliberate rather than incidental. The mature position interviewers look for: derive types where the function IS the source of truth (internal helpers, stores), declare types where the CONTRACT is the source of truth (exported APIs).

async function fetchOrder(id: string) {
  const res = await fetch(`/api/orders/${id}`);
  return (await res.json()) as { id: string; amountPaise: number };
}

type Order = Awaited<ReturnType<typeof fetchOrder>>;
// { id: string; amountPaise: number }

function withRetry<F extends (...args: never[]) => Promise<unknown>>(fn: F) {
  return async (...args: Parameters<F>): Promise<Awaited<ReturnType<F>>> => {
    try { return await fn(...args) as Awaited<ReturnType<F>>; }
    catch { return await fn(...args) as Awaited<ReturnType<F>>; }
  };
}

const fetchOrderSafe = withRetry(fetchOrder); // signature preserved
Q27

Explain mapped types and key remapping with 'as'. How would you build Getters<T> or strip readonly from a type?

IntermediateType-Level Programming

Answer

A mapped type iterates the keys of a type and produces a new type: { [K in keyof T]: F<T[K]> }. During mapping you can add or remove modifiers with + and -: -? strips optionality (how Required works), -readonly strips immutability, +readonly adds it. Homomorphic mapped types, those of the shape [K in keyof T], preserve each property's modifiers and even tuple/array structure from the original, which is why Partial<[string, number]> is a tuple of optionals rather than a mangled object.

TypeScript 4.1 added key remapping: [K in keyof T as NewKey] transforms the key itself, typically with template literal types and the intrinsic string utilities Capitalize, Uncapitalize, Uppercase, Lowercase. Remapping to never FILTERS keys out entirely, which is how you write PickByValue-style types (keep only the string-valued properties, drop functions, etc.). These two features together generate whole API surfaces from a single model: Getters<T> derives getName(): string from name: string; event-map types derive onPaymentFailed from a payment.failed literal; ORM-ish layers derive where-clause types from entity types.

Practical cautions: mapped types over huge unions multiply work for the checker, deep recursive mapping (DeepPartial) hits complexity limits on ORM entity graphs with circular references (Prisma and TypeORM types are the usual victims), and remapped types can produce excellent or terrible editor tooltips depending on whether you let the alias display or force evaluation with the { [K in keyof T]: T[K] } & {} 'prettify' idiom. Being able to write Getters<T> on a whiteboard, modifiers and all, is a very common senior screen at product companies.

type Getters<T> = {
  [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};

type Point = { x: number; y: number };
type PointGetters = Getters<Point>;
// { getX: () => number; getY: () => number }

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };   // strips ? AND undefined

// Filtering by value type: remap unwanted keys to never
type OnlyMethods<T> = {
  [K in keyof T as T[K] extends (...a: never[]) => unknown ? K : never]: T[K];
};

type Prettify<T> = { [K in keyof T]: T[K] } & {}; // flattens tooltips
Q28

How do conditional types work, and how do you combine them with generics to model input-dependent return types?

IntermediateType-Level Programming

Answer

A conditional type, T extends U ? X : Y, is an if-expression at the type level: if T is assignable to U, the type resolves to X, otherwise Y. Alone, it powers small utilities: IsString<T> = T extends string ? true : false.

Combined with generics it models APIs whose return type depends on their input, the thing overloads do irregularly and conditionals do systematically. A function declared <T extends 'user' | 'order'>(kind: T): T extends 'user' ? User : Order returns the precise type for each literal argument, and the conditional stays unevaluated (deferred) inside the generic body, resolving only at each call site.

Three mechanics decide whether you can use these fluently. First, distribution: when the checked type is a NAKED type parameter, the conditional distributes over unions, T = 'a' | 'b' evaluates as the union of the branches; this is usually what you want (Exclude is just T extends U ? never : T) and occasionally a trap (covered in the advanced section). Second, inside the generic function BODY, the compiler cannot prove which branch applies for an unresolved T, so implementations of conditional-return functions typically need a cast or an overload facade; this surprises everyone the first time and is a great honest thing to say in an interview.

Third, assignability into conditionals is conservative: the compiler rarely simplifies A extends B ? ... against partial information, so keep conditionals at the API boundary rather than threading them through many layers. Real uses worth citing: typing a getConfig(key) that returns the correct section per key, event-handler payload selection by event name, and API clients where the route literal determines the response type.

type EndpointMap = {
  '/users':  { users: { id: string }[] };
  '/orders': { orders: { id: string; amountPaise: number }[] };
};

async function apiGet<P extends keyof EndpointMap>(
  path: P,
): Promise<EndpointMap[P]> {
  const res = await fetch(`https://api.goodspace.ai${path}`);
  return res.json() as Promise<EndpointMap[P]>;
}

const data = await apiGet('/orders'); // { orders: {...}[] } precisely

// Conditional return + the in-body limitation
type Parsed<T> = T extends 'json' ? object : string;
function read<T extends 'json' | 'text'>(mode: T): Parsed<T> {
  const raw = '...';
  // return mode === 'json' ? JSON.parse(raw) : raw;  <- still needs a cast:
  return (mode === 'json' ? JSON.parse(raw) : raw) as Parsed<T>;
}

Key Points

  • T extends U ? X : Y, evaluated lazily for generic T
  • Naked type parameters distribute over unions
  • Inside the function body the branch is unprovable: cast or overload facade
  • Best used at API boundaries, not threaded through internals
Q29

What does the infer keyword do inside conditional types? Derive element, resolved-promise, and function-argument types with it.

IntermediateType-Level Programming

Answer

infer declares a type variable INSIDE the extends clause of a conditional type and asks the compiler to solve for it by pattern matching. T extends (infer U)[] ? U : never reads: if T matches the pattern 'array of something', name that something U and return it.

This is how the standard library implements ReturnType (T extends (...args: never) => infer R ? R : never), Parameters, Awaited, and friends, so once you can write infer you can rebuild the utility types from memory, a frequent whiteboard ask. Patterns you should produce fluently: element type of an array, resolved type of a promise (with recursion to unwrap nesting: T extends Promise<infer U> ?

UnwrapPromise<U> : T), first element of a tuple (T extends [infer H, ...infer R] ? H : never, with R capturing the tail thanks to variadic tuples), argument types of a function, and string dissection with template literals (S extends `${infer Head}.${infer Tail}` ? ... splits on the first dot, enabling type-level path parsing like lodash get typing). Details that separate a working answer from a great one: multiple infer sites for the same variable in co-variant positions produce a UNION of candidates, while in contra-variant positions (parameters) they produce an INTERSECTION, which is the trick behind UnionToIntersection; TS 4.7 allows extends constraints directly on infer (infer S extends string) which removes a nesting level from string-manipulating types; and infer only works inside conditional types, it is pattern matching, not a general-purpose variable. Overuse warning for real codebases: deep infer chains are compile-time hot spots and produce opaque errors, so keep them in library-layer code with tests (expectTypeOf) rather than scattered through app code.

type ElementOf<T>  = T extends readonly (infer E)[] ? E : never;
type Resolved<T>   = T extends Promise<infer U> ? Resolved<U> : T;
type FirstArg<F>   = F extends (first: infer A, ...rest: never[]) => unknown ? A : never;
type Head<T>       = T extends [infer H, ...unknown[]] ? H : never;

// Type-level string splitting (powers typed path access)
type PathParts<S extends string> =
  S extends `${infer Head}.${infer Tail}` ? [Head, ...PathParts<Tail>] : [S];

type T1 = ElementOf<string[]>;              // string
type T2 = Resolved<Promise<Promise<number>>>; // number
type T3 = PathParts<'user.address.city'>;   // ['user', 'address', 'city']

// The classic: union to intersection via contravariant infer
type UnionToIntersection<U> =
  (U extends unknown ? (x: U) => void : never) extends (x: infer I) => void
    ? I : never;
Q30

What are template literal types capable of, and where do they show up in production codebases?

IntermediateType-Level Programming

Answer

Template literal types (TS 4.1) apply template-string syntax at the type level: `Bearer ${string}` matches any string with that prefix, and when the placeholders are unions, the type EXPANDS to the cross product: `${'GET' | 'POST'} ${'/users' | '/orders'}` is a four-member union. Combined with Uppercase, Lowercase, Capitalize, Uncapitalize, key remapping in mapped types, and infer-based dissection, they turned strings from opaque blobs into structured data the compiler can parse. Production appearances you can cite concretely: event systems typing emitter.on('payment.failed', handler) where the event-name literal selects the payload type; typed route parameters, extracting { userId: string } from the literal '/users/:userId' via a recursive ExtractParams type, the mechanism behind typed routers in frameworks like Hono and tRPC-adjacent tooling; CSS-in-TS APIs accepting `${number}px` | `${number}rem`; Tailwind-style class name validation in design systems; Redux action namespacing; and i18n libraries that derive the union of valid translation keys from nested resource objects (the pattern i18next's TypeScript integration uses).

Limits you should volunteer: cross products grow multiplicatively and the compiler errors past 100,000 union members ('union type that is too complex to represent'), so three moderate unions in one template can detonate; a template type with an open ${string} placeholder cannot be narrowed by === against another open template; and matching is greedy-less, infer in templates splits at the FIRST separator occurrence, which is exactly what recursive splitters rely on. Also useful: string literal types flow through, so a function <S extends string>(s: S) preserves the caller's literal into the template computation, but only if the parameter is generic, a plain string parameter widens and all the machinery sees just string.

type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
    : Path extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type P = ExtractParams<'/users/:userId/orders/:orderId'>;
// { userId: string; orderId: string }

function route<Path extends string>(
  path: Path,
  handler: (params: ExtractParams<Path>) => void,
) { /* ... */ }

route('/users/:userId', p => p.userId);   // OK
// route('/users/:userId', p => p.orderId) -> Error: no such param

type EventName = `${'user' | 'order'}.${'created' | 'deleted'}`; // 4 members
Q31

Design a discriminated union for an async data-fetching state and show how exhaustiveness checking catches future changes.

IntermediateType Design

Answer

A discriminated union models 'one of N states' with a shared literal-typed discriminant property; checking the discriminant narrows to the exact member, giving you compiler-enforced state machines. The async-request example is the canonical one because the naive alternative, { loading: boolean; data?: T; error?: Error }, permits nonsense states (loading with data, error with data) that every consumer must defensively handle. The union version makes those states unrepresentable: 'idle' and 'loading' carry no data field AT ALL, so touching .data without narrowing is a compile error, not a runtime undefined.

The discriminant must be a literal type (string literal, numeric literal, true/false, or unique symbol), and each member's literal must be distinct; a common failure is letting the status field widen to string by building members through untyped helpers. Exhaustiveness checking is the second half: switch on the discriminant, and in the default branch assign the value to never (directly or through an assertNever helper that throws). While all members are handled, the default value has type never and the assignment checks; the day a teammate adds a 'refetching' member, every switch missing it stops compiling, turning a silent UI bug into a build failure.

This scales beyond UI: payment lifecycle states, WebSocket message protocols (discriminate on type, each message gets its own payload shape), form validation results, and reducer actions all use the identical pattern. Two refinements worth mentioning: with the noFallthroughCasesInSwitch and switch-exhaustiveness-check lint rules you get overlapping protection, and if a function RETURNS per-state values, making its return type depend on the full union means adding a member also surfaces every return site to update.

type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T; fetchedAt: number }
  | { status: 'error'; error: Error; retryCount: number };

function assertNever(x: never): never {
  throw new Error(`Unhandled state: ${JSON.stringify(x)}`);
}

function render(state: FetchState<string[]>): string {
  switch (state.status) {
    case 'idle':    return 'Press load';
    case 'loading': return 'Spinner';
    case 'success': return state.data.join(', '); // data exists ONLY here
    case 'error':   return `Failed after ${state.retryCount} tries`;
    default:        return assertNever(state); // add a member -> compile error
  }
}

Key Points

  • Optional-flag shapes allow impossible states; unions forbid them
  • Discriminant must be a distinct literal type per member
  • assertNever in default turns forgotten cases into build failures
  • Same pattern types reducers, message protocols, and payment lifecycles
Q32

User-defined type guards ('is') and assertion functions ('asserts'): how do you write them, and what risks do they carry?

IntermediateNarrowing

Answer

A user-defined type guard is a function whose return type is a type predicate: function isOrder(v: unknown): v is Order. When it returns true, the compiler narrows the argument to Order in the guarded branch; when false, it subtracts Order where possible. Guards are how you centralise runtime shape checks: filtering arrays (list.filter(isOrder) produces Order[], and note plain list.filter(x => x !== null) also narrows since TS 5.5's inferred type predicates, but only for simple single-check arrows), narrowing unknown from JSON, and discriminating union members where the built-in narrowing operators cannot reach (nested checks, Array checks, regex validation).

Assertion functions are the throwing sibling: asserts v is Order declares that IF the function returns, the value is an Order, so the narrowing applies to all following code with no if statement; asserts cond (no type) models invariant helpers like Node's assert, narrowing whatever the condition expression implies. Both features share one crucial risk: the predicate is TRUSTED, not verified. The compiler does not check that your implementation actually proves what the signature claims; return true unconditionally and you have manufactured type-unsafety with extra steps.

This makes guards the highest-leverage place for review and tests in a codebase: a wrong guard poisons every call site that relies on it. Mitigations interviewers like to hear: keep guards small and property-complete (check every field you claim), generate them from schemas instead of writing them (Zod's .safeParse or typia's createIs give you guards that provably match the type), and add unit tests feeding hostile inputs. One syntax gotcha: assertion functions must have an EXPLICIT type annotation at the call site binding (const assertOrder: (v: unknown) => asserts v is Order = ...), arrow-function inference alone does not propagate asserts.

type Order = { id: string; amountPaise: number };

function isOrder(v: unknown): v is Order {
  return (
    typeof v === 'object' && v !== null &&
    typeof (v as Order).id === 'string' &&
    typeof (v as Order).amountPaise === 'number'
  );
}

const mixed: unknown[] = loadBatch();
const orders = mixed.filter(isOrder);       // Order[]

function assertOrder(v: unknown): asserts v is Order {
  if (!isOrder(v)) throw new Error('Payload is not an Order');
}

const payload: unknown = JSON.parse(body);
assertOrder(payload);
payload.amountPaise; // narrowed from here on, no if-block nesting
💡 Pro Tip: Treat every 'v is T' function as a security boundary: it can silently lie. Prefer deriving guards from Zod schemas so the check and the type cannot drift apart.
Q33

Generic constraints, default type parameters, and NoInfer: how do you control what the compiler infers?

IntermediateGenerics

Answer

extends on a type parameter constrains what callers may instantiate it with AND tells the compiler what operations are legal inside: <T extends { id: string }> lets the body read .id. Constraints interact with inference subtly: an unconstrained T infers freely from arguments; a constrained one still infers from arguments but rejects instantiations violating the constraint. The classic K extends keyof T dependency between parameters is what makes property accessors and pluck-style functions safe.

Default type parameters (<T = string>) fill in when neither an explicit argument nor inference supplies one, common on container and options types (class EventBus<Events = DefaultEvents>) and on React-style component generics; note defaults do not affect inference priority, they only apply when inference produces nothing. The pain point that motivated NoInfer (a built-in intrinsic since TS 5.4): when the same T appears in multiple argument positions, every position is an inference site, and sometimes one position should CHECK against T rather than WIDEN it. The textbook case is createStreetLight(colors: C[], defaultColor: C): passing ['red', 'green'] with defaultColor 'blue' happily infers C as 'red' | 'green' | 'blue', defeating the point.

Wrapping the checking position as NoInfer<C> excludes it from inference, so C is inferred only from colors and 'blue' errors. Before 5.4 teams simulated this with [T][T extends any ? 0 : never] tricks; the intrinsic is faster and readable. Also worth knowing: const type parameters (<const T>) push inference toward literal types without callers writing as const, and each additional inference site or constraint has compile-time cost, so library authors profile heavily generic APIs. Interviewers often ask you to explain an inference result: walking through 'which positions are inference sites, what candidates did each produce, how were they reconciled' is the systematic answer they want.

function createStreetLight<C extends string>(
  colors: C[],
  defaultColor: NoInfer<C>,
) { /* ... */ }

createStreetLight(['red', 'green'], 'red');   // OK
// createStreetLight(['red', 'green'], 'blue')
// Error: 'blue' is not assignable to 'red' | 'green'

// Dependent constraints between parameters
function setProp<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

// const type parameter: literal inference without caller-side 'as const'
function defineRoles<const T extends readonly string[]>(roles: T): T {
  return roles;
}
const roles = defineRoles(['admin', 'editor']);
// readonly ['admin', 'editor'], not string[]
Q34

Covariance, contravariance, and strictFunctionTypes: why are method parameters checked differently from function-property parameters?

IntermediateType System

Answer

Variance describes how assignability of a composite type follows from its parts. Return types are covariant: () => Dog is assignable to () => Animal, a function producing something more specific is a fine substitute. Parameter types are CONTRAvariant: (a: Animal) => void is assignable to (a: Dog) => void, a handler accepting MORE is a fine substitute where less is expected, but not the reverse: passing (d: Dog) => void where (a: Animal) => void is expected would let someone call it with a Cat. strictFunctionTypes (in the strict family) enforces this correct contravariant checking, but ONLY for function-type properties and standalone function types.

Parameters of METHODS declared with method shorthand syntax (m(x: Dog): void inside an interface or class) are still checked BIVARIANTLY, accepting both directions, an intentional unsoundness kept because vast amounts of real code depends on it: the standard DOM types alone would break, since Array<Dog> being assignable to Array<Animal> (which everyone expects) relies on push(item: Dog) being bivariantly compatible. So the practical rule: declare callbacks and handler fields with property-arrow syntax (onEvent: (e: PaymentEvent) => void) to get sound checking, and be aware that method syntax opts into looseness. This distinction bites in real code through event systems: a handler map typed with method syntax will accept a handler for the wrong event subtype and crash at runtime. Two adjacent facts complete a strong answer: arrays are unsoundly covariant in TypeScript (Dog[] assignable to Animal[], then push a Cat through the Animal[] alias, blowing up later), a deliberate ergonomic trade-off, with readonly T[] restoring safety by removing mutation; and TS 4.7 added explicit in/out variance annotations for type parameters, covered in the advanced section.

type Animal = { name: string };
type Dog = Animal & { breed: string };

// Property syntax: SOUND (contravariant params) under strictFunctionTypes
interface SafeEmitter { handle: (d: Dog) => void }
declare const acceptsAnimal: (a: Animal) => void;
const ok: SafeEmitter = { handle: acceptsAnimal };  // OK: wider param fine

// Method syntax: BIVARIANT (unsound, by design)
interface LooseEmitter { handle(d: Animal): void }
declare const needsDog: (d: Dog) => void;
const risky: LooseEmitter = { handle: needsDog };   // accepted!
risky.handle({ name: 'cat' }); // runtime: reads .breed of undefined

// Array covariance leak, fixed by readonly
const dogs: Dog[] = [];
const animals: Animal[] = dogs;   // allowed
animals.push({ name: 'cat' });    // dogs now contains a non-Dog

Key Points

  • Returns covariant, parameters contravariant: substitutability rules
  • strictFunctionTypes fixes function properties, NOT shorthand methods
  • Declare handler fields with arrow-property syntax for soundness
  • Mutable arrays are unsoundly covariant; readonly T[] restores safety
Q35

How do you handle errors in a typed way: unknown in catch, narrowing Error subtypes, Error.cause, and result types?

IntermediateError Handling

Answer

JavaScript can throw ANY value, so TypeScript cannot give catch variables a useful type: under useUnknownInCatchVariables (default with strict since TS 4.4) they are unknown, forcing narrowing before use. The baseline pattern is instanceof Error, then reading .message and .stack; for anything beyond that, model your own error hierarchy: class PaymentError extends Error with a code field, then instanceof PaymentError narrows precisely. Two footguns around subclassing Error: when the compilation target is es5, the emitted helper breaks the prototype chain and instanceof fails silently (fixed by Object.setPrototypeOf in the constructor, or by targeting es2015+, which every 2026 project should); and always set this.name so logs and error trackers group correctly.

Error.cause (standard since ES2022, typed in lib.es2022.error.d.ts) lets you wrap low-level errors while preserving the chain: throw new PaymentError('capture failed', { cause: err }), and observability tools increasingly render the cause chain, which beats string-concatenating messages. For libraries and service boundaries where exceptions feel too untyped, the Result pattern makes failure part of the signature: type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }, a discriminated union callers MUST narrow, giving exhaustive handling of enumerated failure modes (great for parsing, validation, and domain rules; overkill for truly exceptional conditions). Fit these together in practice: throw typed errors within a bounded context, catch at the boundary, narrow with instanceof and error-code discriminants, wrap with cause when re-throwing across layers, and convert to Result where the caller is expected to branch. Mention @typescript-eslint/only-throw-error (formerly no-throw-literal) to ban throwing non-Errors, which keeps the instanceof Error narrowing reliable across the whole codebase.

class ApiError extends Error {
  constructor(
    message: string,
    readonly status: number,
    options?: { cause?: unknown },
  ) {
    super(message, options);
    this.name = 'ApiError';
  }
}

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

async function chargeCard(orderId: string): Promise<Result<string, ApiError>> {
  try {
    const id = await gateway.capture(orderId);
    return { ok: true, value: id };
  } catch (err) {                       // err: unknown
    if (err instanceof ApiError) return { ok: false, error: err };
    throw new ApiError('capture failed', 502, { cause: err }); // chain kept
  }
}

const r = await chargeCard('o1');
if (!r.ok) console.error(r.error.status); // must narrow before .value
Q36

Index signatures vs Record vs Map, and what does noUncheckedIndexedAccess change about reading them?

IntermediateType System

Answer

An index signature ({ [key: string]: number }) says 'any string key yields a number', which is a lie for every key the object does not actually have: by default reading a missing key types as number while being undefined at runtime. noUncheckedIndexedAccess fixes the lie: every index-signature and array-element read becomes T | undefined, forcing a check, a default (??), or a non-null assertion you can justify. The flag is not part of strict and its adoption is a real signal of codebase maturity; the cost is friction in obviously-safe loops (for loop over arr.length still yields T | undefined on arr[i]), which teams route around with for-of, .at(), or entries(). Record<K, V> with a LITERAL union K is a different beast entirely: it demands exactly those keys, all present, so reads are safe without the flag and exhaustiveness comes free.

So the decision tree: closed key set known at compile time, use Record over a union (or an interface); open-ended string keys with homogeneous values, use an index signature and turn on noUncheckedIndexedAccess; keys computed at runtime, keys that are not strings/numbers/symbols, frequent add/delete, or iteration-ordering concerns, use Map<K, V>, whose .get honestly returns V | undefined and always has. Map also avoids two plain-object hazards worth naming: prototype pollution style key collisions ('__proto__', 'constructor' as user-supplied keys, a real vulnerability class in JS backends) and the accidental inherited-property reads that in-operator checks can hit; Object.create(null) is the old mitigation, Map the modern one. One more subtlety: index signatures interact with declared properties, a declared count: number must be compatible with the string index's value type, and noPropertyAccessFromIndexSignature (another opt-in flag) forces bracket notation for index-signature reads so declared and dynamic access LOOK different in code review.

// tsconfig: { "noUncheckedIndexedAccess": true }

const tally: { [word: string]: number } = {};
// tally['hello'] + 1  -> Error: possibly undefined
tally['hello'] = (tally['hello'] ?? 0) + 1;   // idiomatic upsert

const byStatus: Record<'open' | 'closed', number> = { open: 2, closed: 5 };
byStatus.open;      // number, no undefined: keys are guaranteed present

const cache = new Map<string, { html: string }>();
const hit = cache.get(url);        // honestly { html: string } | undefined
if (hit) return hit.html;

const arr = [1, 2, 3];
const last = arr[arr.length - 1];  // number | undefined under the flag
const safe = arr.at(-1) ?? 0;

Key Points

  • Index signatures lie about missing keys until noUncheckedIndexedAccess
  • Record over a literal union = closed, exhaustive, safe reads
  • Map for runtime-computed keys; it dodges __proto__ pollution too
  • Array indexing also returns T | undefined under the flag; prefer .at()/for-of
Q37

ESM vs CommonJS in TypeScript: how do module and moduleResolution interact, and what causes the classic runtime failures?

IntermediateModules

Answer

TypeScript sits on top of a runtime split it does not control: Node treats .mjs (or .js under "type": "module") as ESM and .cjs/.js otherwise as CommonJS, and the two have different resolution and interop rules. The compiler settings must MATCH how the code will actually be loaded, and mismatches produce errors that type-check fine and explode at runtime. Failure one: ERR_MODULE_NOT_FOUND because a relative import lacks an extension; Node ESM requires the full './helper.js' (yes, .js even though you wrote helper.ts, you import the emitted name); module nodenext enforces this at compile time, moduleResolution bundler does not, so code checked as bundler but run directly by Node breaks.

Failure two: ERR_REQUIRE_ESM, a CommonJS file require()-ing an ESM-only package (the wave of ESM-only releases of popular packages made this a rite of passage); fixes are migrating the consumer to ESM, dynamic import(), or staying on a dual-published version, and recent Node has learned to require() synchronous ESM graphs, which is easing the pain. Failure three: default-import interop, a CJS module's module.exports maps awkwardly onto ESM defaults; esModuleInterop makes import express from 'express' work by synthesising defaults, but under true nodenext resolution the shape depends on the package's exports map and types can disagree with runtime, the notorious 'default is not a function' class of bug. The TS 5.x era guidance an interviewer wants to hear: applications bundled by Vite/esbuild/webpack use module esnext (or preserve) with moduleResolution bundler and let the bundler own reality; code executed directly by Node uses module nodenext everywhere; libraries publish with exports maps declaring both import and require conditions with matching types conditions per entry, verified with the arethetypeswrong tool (attw) which catches masquerading and wrong-condition bugs the compiler cannot see from inside the package.

// package.json (dual-publishing library, checked by arethetypeswrong)
{
  "name": "@goodspace/sdk",
  "type": "module",
  "exports": {
    ".": {
      "import": { "types": "./dist/esm/index.d.ts",  "default": "./dist/esm/index.js" },
      "require": { "types": "./dist/cjs/index.d.cts", "default": "./dist/cjs/index.cjs" }
    }
  }
}

// ESM source under module: nodenext
import { verify } from './signature.js';  // extension mandatory, points at OUTPUT
const { createHmac } = await import('node:crypto'); // dynamic import: fine anywhere

Key Points

  • Compiler module settings must match how Node will actually load the file
  • ESM relative imports need explicit .js extensions under nodenext
  • esModuleInterop papers over CJS default-import mismatch, imperfectly
  • Libraries: exports map + per-condition types, validated with attw
Q38

How does module augmentation work? Add a user field to Express's Request and type process.env safely.

IntermediateModules

Answer

Module augmentation uses declaration merging to extend types you do not own. Inside a file that is itself a module (has at least one import/export), declare module 'express-serve-static-core' { interface Request { user?: AuthUser } } merges your member into the library's interface everywhere in the project. The mechanics that trip people up: the augmented module NAME must be the one that actually declares the interface (for Express's Request that is express-serve-static-core, not 'express', a detail that has burned nearly everyone); the file must be included in the compilation (check tsconfig include globs, misplaced d.ts files silently do nothing); and augmentation can only MERGE into existing declarations, it cannot change existing member types, so you cannot 'fix' a library's wrong type this way, only add.

For globals, the pattern is declare global { ... } inside a module file: the canonical example is typing environment variables by augmenting NodeJS.ProcessEnv with your known keys, giving autocomplete and typo protection on process.env.DATABASE_URL across the codebase (note all values remain string | undefined semantics decisions: declare them string and enforce presence with a boot-time validator like Zod on process.env, the honest setup validates once and exports a typed config object instead). Real-world augmentation sites worth citing: adding fields to Express/Fastify request objects populated by auth middleware, extending Vue's ComponentCustomProperties or React's JSX namespace, typing theme objects in styled-components via DefaultTheme, and adding custom matchers to Vitest/Jest's expect interface. The caveat that shows judgment: augmentations are global and invisible at use sites, so overuse turns the type system into spooky action at a distance; teams typically corral them into a types/ directory with one file per augmented package, and prefer explicit wrapper types where feasible.

// types/express.d.ts (must be inside tsconfig 'include')
import type { AuthUser } from '../src/auth/types';

declare module 'express-serve-static-core' {
  interface Request {
    user?: AuthUser;          // set by auth middleware
    requestId: string;        // set by tracing middleware
  }
}

// types/env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      DATABASE_URL: string;
      REDIS_URL: string;
      NODE_ENV: 'development' | 'production' | 'test';
    }
  }
}
export {};

// Boot-time enforcement so the declared types are actually TRUE
import { z } from 'zod';
export const env = z.object({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
}).parse(process.env);
Q39

Static types stop at the network boundary. How do Zod (or similar) schemas bridge runtime validation and compile-time types?

IntermediateRuntime Validation

Answer

The compiler verifies your code against DECLARED types, but data entering the process (HTTP bodies, third-party APIs, queue messages, env vars, localStorage) has whatever shape it actually has. res.json() as User is unverified trust; when the upstream renames a field, nothing fails until a distant property read returns undefined. Schema libraries close the gap by making the RUNTIME validator the source of truth from which the STATIC type is derived: with Zod you define const UserSchema = z.object({...}) once, z.infer<typeof UserSchema> gives the exact TypeScript type, and .parse(data) throws (or .safeParse returns a discriminated union result) when reality disagrees. Because type and validator are one artifact, they cannot drift, the property that separates this from hand-written guards.

Production patterns to name: validate at every ingress and NOWHERE else (validated data flows inward as trusted typed values, re-validating internally is noise); use .safeParse at boundaries where failure is expected and handleable, .parse where malformed data is a bug worth a 500 and a log line; z.discriminatedUnion for tagged payloads gives both faster checks and better error messages than a plain union; transforms (.transform, z.coerce.number()) parse-and-convert in one step, e.g. string query params into numbers; and schemas compose, so DTO evolution happens in one file. The ecosystem context interviewers expect in 2026: Zod is the incumbent (v4 substantially improved parse performance and error customisation); Valibot offers a tree-shakeable alternative popular in frontend bundles; typia and TypeBox flip the direction (types or JSON Schema first); and the Standard Schema specification lets frameworks (tRPC, Hono, TanStack tooling) accept any compliant library interchangeably. The design trade-off to acknowledge: validation costs CPU on hot paths, so very high-throughput internal services sometimes validate at the edge only and rely on contract tests between trusted services instead.

import { z } from 'zod';

const WebhookSchema = z.discriminatedUnion('event', [
  z.object({
    event: z.literal('payment.captured'),
    payload: z.object({ orderId: z.string(), amountPaise: z.number().int() }),
  }),
  z.object({
    event: z.literal('payment.failed'),
    payload: z.object({ orderId: z.string(), reason: z.string() }),
  }),
]);

type Webhook = z.infer<typeof WebhookSchema>; // derived, cannot drift

app.post('/webhooks/razorpay', (req, res) => {
  const parsed = WebhookSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ issues: parsed.error.issues });
  }
  const hook = parsed.data;               // fully typed union
  if (hook.event === 'payment.failed') {
    queueRetry(hook.payload.orderId, hook.payload.reason);
  }
  res.sendStatus(200);
});

Key Points

  • Deriving types from schemas (z.infer) makes drift impossible
  • Validate at ingress only; typed data flows inward as trusted
  • safeParse for expected failure, parse for bug-class failure
  • Know the landscape: Zod, Valibot, TypeBox, typia, Standard Schema
Q40

How do project references, composite, and incremental builds speed up large TypeScript repos?

IntermediateBuild & Scale

Answer

Past a few hundred thousand lines, single-tsconfig type checking becomes the slowest step in CI and the editor. Project references split the repo into smaller projects with explicit dependency edges: each package gets a tsconfig with composite: true (which forces declaration: true and requires include/files to be explicit), and dependents list { "references": [{ "path": "../core" }] }. Building with tsc -b (build mode) then does three valuable things: builds projects in dependency order, type-checks each against the .d.ts OUTPUT of its dependencies rather than their source (so a change that does not alter core's public declarations does not re-check consumers), and skips projects whose inputs are unchanged, tracked via .tsbuildinfo files written by the incremental machinery. incremental: true alone (without references) already persists the dependency graph between runs, turning warm no-change checks from minutes to seconds; references add cross-project caching and parallelism on top.

The operational details that show real experience: .tsbuildinfo files must be cached in CI (and invalidated on compiler upgrades) or you silently pay cold-build cost every run; declarationMap: true makes go-to-definition land in source instead of .d.ts, without it editor navigation degrades badly; build mode does NOT emit with noEmit, referenced projects must emit declarations somewhere (an outDir or dist/types); and circular references between projects are a hard error, which conveniently forces layering hygiene. In monorepos, teams either wire references by hand, generate them from the workspace graph, or skip tsc orchestration entirely and let Nx/Turborepo cache per-package tsc --noEmit runs keyed on file hashes, a legitimate alternative worth mentioning. The endgame context: the Go-native compiler (tsgo, the TypeScript 7 effort) attacks the same pain from the raw-speed side with roughly 10x checking speedups reported on large codebases, but graph-aware builds remain valuable because not re-checking at all still beats re-checking fast.

// packages/core/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

// packages/api/tsconfig.json
{
  "compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
  "references": [{ "path": "../core" }],
  "include": ["src"]
}

# Ordered, cached, parallel-friendly build of the whole graph
npx tsc -b packages/api --verbose
# CI: cache **/*.tsbuildinfo keyed on lockfile + TS version
Q41

How do you test TypeScript in 2026: Vitest vs Jest transforms, and how do you test the types themselves?

IntermediateTesting

Answer

Two layers: testing behaviour and testing types. For behaviour, the transform pipeline is the decision that bites. Vitest has become the default for new projects because it consumes TypeScript natively through Vite's esbuild transform: zero transform config, fast watch mode, ESM-first, and jest-compatible APIs (describe/it/expect) so migration is mostly import changes.

Jest remains everywhere in existing codebases; there you pick a transformer: ts-jest (runs the real compiler, can type-check inside tests but is slow) or @swc/jest and babel-jest (fast, but strip types WITHOUT checking, and Babel's isolated-file transform historically mishandled const enums and namespace tricks). The trap candidates should articulate: with esbuild/swc-based runners, type errors DO NOT fail tests, a test file full of type errors runs green, so CI needs a separate tsc --noEmit that includes test files (a tsconfig whose include covers **/*.test.ts). For the types themselves, use type-level assertions: Vitest ships expectTypeOf and assertType (expectTypeOf(fn).returns.toEqualTypeOf<Order[]>()), checked by running vitest --typecheck which invokes tsc on the special *.test-d.ts files; the older tsd package does the same for libraries; and the @ts-expect-error directive is the standard way to assert something does NOT compile, it errors when the following line unexpectedly succeeds, making 'this misuse must stay impossible' a regression-tested invariant (always with a trailing description, enforced by the ban-ts-comment lint rule).

Library authors add API-surface snapshotting: generate .d.ts in CI and diff it (or use api-extractor) so accidental breaking changes to public types fail the build. Mock typing rounds it out: vi.mocked(dep) or jest.mocked(dep) preserves signatures on mocks, so refactors break tests at compile time instead of producing mocks that silently accept anything.

// price.test-d.ts, run via: vitest --typecheck
import { expectTypeOf } from 'vitest';
import { toPaise, parseAmount } from './price';

expectTypeOf(toPaise).parameter(0).toEqualTypeOf<number>();
expectTypeOf(parseAmount('₹499')).toEqualTypeOf<
  { ok: true; paise: number } | { ok: false; reason: string }
>();

// Asserting that misuse KEEPS failing to compile
// @ts-expect-error toPaise must not accept a string amount
toPaise('499');

// Typed mocks keep refactors honest
import { vi } from 'vitest';
import * as gateway from './gateway';
vi.spyOn(gateway, 'capture').mockResolvedValue({ paymentId: 'pay_1' });
// change capture's return type -> this line errors at compile time

Key Points

  • esbuild/swc runners skip type checking: CI needs tsc --noEmit over tests too
  • expectTypeOf / tsd make types themselves regression-testable
  • @ts-expect-error asserts that a misuse cannot compile
  • vi.mocked / jest.mocked keep mock signatures in sync with reality
Q42

Which typescript-eslint rules add real safety beyond the compiler, and what is typed linting's cost?

IntermediateTooling

Answer

The compiler enforces the type system; typescript-eslint enforces how you USE it, and its most valuable rules are the type-aware ones that need a program behind them (parserOptions.projectService in modern flat configs). The ones worth naming individually: no-floating-promises flags unawaited promises, the number-one source of swallowed rejections and out-of-order side effects in Node backends (with the void operator as the sanctioned 'intentionally fire-and-forget' marker); no-misused-promises catches async functions passed where void-returning callbacks are expected, in if-conditions, and in forEach, the sibling bug; await-thenable flags pointless awaits on non-promises which often reveal a misunderstanding; no-unsafe-assignment/-member-access/-call/-return (the no-unsafe family) fence in any at its point of entry, effectively extending noImplicitAny to every EXPRESSION of any flowing out of untyped libraries; switch-exhaustiveness-check complements never-based exhaustiveness for switches without default; only-throw-error keeps instanceof Error narrowing dependable; restrict-template-expressions stops objects stringifying to [object Object] in log lines; and consistent-type-imports auto-fixes the import type discipline that verbatimModuleSyntax demands. The recommended entry point is the recommended-type-checked (or stricter strict-type-checked) shared config rather than hand-picking.

The honest cost side: type-aware linting runs the checker, so it is several times slower than syntax-only linting, noticeable on large repos and pre-commit hooks; mitigations are running the typed ruleset in CI but a lighter set in hooks, project-service caching, and scoping typed rules to src while tests get a relaxed layer (unbound-method, for instance, false-positives on mock frameworks). Worth one closing sentence in an interview: linters and the compiler overlap less than people assume, tsc will happily compile a floating promise, and ESLint will happily pass a type error, you need both gates.

// eslint.config.mjs (flat config)
import tseslint from 'typescript-eslint';

export default tseslint.config(
  ...tseslint.configs.strictTypeChecked,
  {
    languageOptions: {
      parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
    },
    rules: {
      '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }],
      '@typescript-eslint/switch-exhaustiveness-check': 'error',
      '@typescript-eslint/consistent-type-imports': 'error',
    },
  },
);

// What the rules catch that tsc allows:
async function persist() { /* ... */ }
persist();            // no-floating-promises: rejection would vanish
void persist();       // sanctioned fire-and-forget
[1, 2].forEach(async n => save(n)); // no-misused-promises: unawaited chaos
Q43

What does exactOptionalPropertyTypes change, and why does 'missing' vs 'explicitly undefined' matter in real systems?

IntermediateCompiler Configuration

Answer

Without the flag, an optional property name?: string is treated as name?: string | undefined in BOTH directions: you may omit it, and you may also explicitly assign undefined. exactOptionalPropertyTypes makes the declaration mean exactly what it says: the property may be ABSENT, but if present its value must be a string, assigning undefined explicitly becomes an error unless the type includes undefined on purpose. The distinction sounds pedantic until you meet the systems where absence and undefined diverge. JSON.stringify drops absent and undefined-valued properties alike, but Object.keys, the in operator, property spread, and hasOwnProperty all SEE an explicitly-undefined property; a merge utility that spreads a patch object ({ ...current, ...patch }) will OVERWRITE a real value with undefined if the patch carried the key explicitly, exactly the shape of 'the update form wiped my profile field' bugs.

Database layers make it sharper: in a typical ORM update payload, an absent key means 'leave the column alone' while an explicit undefined or null often means 'set it', Prisma, for example, historically treated explicitly-undefined fields as 'skip', then added strictUndefinedChecks to reject them outright because the ambiguity caused accidental data loss; typed codebases mirror that decision at compile time with this flag. Practical consequences of enabling it: patterns like obj.prop = maybeUndefined need restructuring (conditionally add the key, or type the property as string | undefined deliberately); deleting keys becomes the honest way to remove them; and some third-party types that play loose with optionality start erroring, which is why the flag is off by default and adoption usually accompanies a cleanup pass. Interviewers pair this with a follow-up on partial updates: the strong answer distinguishes three states in the API contract, absent (no change), null (clear the value), and present (set it), and notes that ?: with exactOptionalPropertyTypes plus | null models precisely that triad.

// tsconfig: { "exactOptionalPropertyTypes": true }

type ProfilePatch = {
  bio?: string | null;   // absent = no change, null = clear, string = set
  city?: string;
};

const patch: ProfilePatch = { bio: undefined };
// Error: 'undefined' not assignable to 'string | null' with the flag on

function applyPatch(current: Profile, patch: ProfilePatch) {
  const next = { ...current };
  if ('bio' in patch) {
    next.bio = patch.bio === null ? '' : patch.bio!;
  }                       // key-presence check, not value check
  return next;
}

// The bug the flag prevents:
// { ...profile, ...{ city: undefined } } -> city wiped despite 'optional'
Q44

Typing asynchronous code: what do Promise.all and allSettled infer, and where do typed async patterns go wrong?

IntermediateAsync

Answer

An async function's declared return type is always Promise<T>; returning a plain value wraps it, returning a promise flattens it (never Promise<Promise<T>>), and await's result is typed via the Awaited utility, which also unwraps thenables. Promise.all is variadic-tuple-typed: awaiting Promise.all([fetchUser(), fetchOrders()]) infers a TUPLE [User, Order[]], so destructuring keeps precise per-position types, one of the quiet wins of TS 4.x-era lib typings. Its failure semantics are the classic probe: all rejects on the FIRST rejection while the other promises continue running unobserved, so partial-failure workflows belong to Promise.allSettled, whose result type is a discriminated union per slot, { status: 'fulfilled'; value: T } | { status: 'rejected'; reason: unknown }, forcing you to narrow before touching value, the type system teaching correct error handling.

Promise.race types as the union of its inputs' resolutions; Promise.any resolves on first success and rejects with AggregateError. The recurring type-adjacent bugs: floating promises (calling an async function without await/then in a void context, rejections become unhandledRejection crashes in Node, no-floating-promises is the guard); sequential awaits where work is independent (await a; await b; doubles latency versus all, a code-review staple); async executors inside new Promise (the promise-constructor anti-pattern, rejections inside are lost); and forEach with async callbacks, which neither awaits nor surfaces errors, use for-of with await, or map into all. Two typing subtleties worth volunteering: rejection is UNTYPED, Promise<T> says nothing about the error channel, which is a language limitation you handle with unknown-narrowing in catch or Result types; and an overload-adjacent trap, typing a callback parameter as () => void will happily accept an async function, so declare () => Promise<void> (or void | Promise<void>) where you intend to await it. AbortController rounds out modern async: fetch and many Node APIs take a typed signal, and cancellation-aware helpers should thread AbortSignal through their signatures.

async function loadDashboard(userId: string) {
  const [user, orders] = await Promise.all([
    fetchUser(userId),
    fetchOrders(userId),
  ]); // tuple-typed: user: User, orders: Order[]

  const results = await Promise.allSettled(
    orders.map(o => refreshInvoice(o.id)),
  );
  const failed = results.filter(
    (r): r is PromiseRejectedResult => r.status === 'rejected',
  );
  if (failed.length) logWarn(`${failed.length} invoice refreshes failed`);
}

// Latency bug tsc will never catch:
const a = await slowA();   // 300ms
const b = await slowB();   // +300ms sequential, though independent

// Cancellation-aware signature
async function search(q: string, opts?: { signal?: AbortSignal }) {
  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, opts);
  return res.json();
}
Q45

What TypeScript patterns matter most in React codebases: typing props, children, events, refs, and generic components?

IntermediateReact Integration

Answer

React interviews at Indian product companies almost always include a TypeScript round, and a cluster of patterns decides it. Props: type them with an interface or type alias and a plain function component signature (function Button(props: ButtonProps)); the once-ubiquitous React.FC has fallen out of favour since it stopped implicitly injecting children in React 18's types and adds little, declare children yourself as React.ReactNode, the correct wide type for renderable content (ReactElement is narrower: an actual element only, no strings or numbers). Extending native elements: interface Props extends React.ComponentPropsWithoutRef<'button'> lets your design-system button accept every real button attribute without hand-listing them, the single most useful React-TS idiom; ComponentProps<typeof SomeComponent> does the same for components.

Events: handlers take React's synthetic types, React.ChangeEvent<HTMLInputElement>, React.MouseEvent<HTMLButtonElement>, and inline handlers get these via contextual typing, so extracting a handler to a named function is when you must spell them out. Refs: useRef<HTMLInputElement>(null) produces a ref whose .current is HTMLInputElement | null, and with React 19 ref became an ordinary prop so forwardRef (and its awkward typing) is no longer needed on new code, a version-aware detail interviewers increasingly expect. Hooks: useState infers from the initial value, but nullable-then-loaded data needs the explicit form useState<User | null>(null); reducers pair with discriminated-union action types so the switch narrows payloads per action.

Generic components, a list component <T,>({ items, renderItem }: { items: T[]; renderItem: (t: T) => ReactNode }), preserve item types through to render callbacks (note the <T,> comma in .tsx to avoid JSX ambiguity). Finally, know that state setters accept updater functions typed (prev: T) => T, and that discriminated-union PROPS (either href or onClick, never both) push API misuse to compile time, the pattern design-system teams at scale rely on.

import { type ReactNode, useRef, useState } from 'react';

interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
  variant: 'primary' | 'ghost';
  children: ReactNode;
}

export function Button({ variant, children, ...rest }: ButtonProps) {
  return <button data-variant={variant} {...rest}>{children}</button>;
}

// Generic list component: item type flows into the render prop
function List<T,>({ items, renderItem }: {
  items: readonly T[];
  renderItem: (item: T) => ReactNode;
}) {
  return <ul>{items.map((it, i) => <li key={i}>{renderItem(it)}</li>)}</ul>;
}

function Search() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [user, setUser] = useState<{ name: string } | null>(null);
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) =>
    setUser({ name: e.target.value });
  return <input ref={inputRef} onChange={onChange} value={user?.name ?? ''} />;
}
Q46

Node.js can now run TypeScript files directly. How does type stripping work, and what does erasableSyntaxOnly enforce?

IntermediateRuntime & Versions

Answer

Recent Node versions execute .ts files by TYPE STRIPPING: a lightweight transform (built on a Rust port of swc machinery) replaces type annotations with whitespace and runs the result, preserving line/column positions so stack traces need no source maps. Crucially, it is not compilation: no type checking happens (Node will happily run type-incorrect code), and no code GENERATION happens, which is where the constraint comes from. TypeScript constructs that require emitting real JavaScript, enums, namespaces containing values, parameter properties in constructors, and legacy experimentalDecorators metadata, cannot be stripped to whitespace; under plain stripping they throw at load time (Node's separate transform-types mode can compile some of them, but the default posture and the direction of the ecosystem is erasable-only).

TypeScript 5.8 added the erasableSyntaxOnly compiler flag so the CHECKER enforces the same rule: enable it and every enum, value-namespace, and parameter property becomes a compile error with suggested rewrites (as-const objects for enums, plain modules for namespaces, explicit field assignments for parameter properties). Adjacent details that make an answer complete: verbatimModuleSyntax pairs naturally, since single-file stripping also cannot infer which imports are type-only, import type discipline becomes mandatory; file extensions in imports follow Node ESM rules; tsconfig target/module settings do not affect stripping (the runtime ignores them, another reason to keep code modern-syntax); and the same erasable-only philosophy is what keeps code portable across Deno, Bun, and the proposed ECMAScript type-annotations direction. Positioning for interviews: type stripping kills the compile step for scripts, tools, and services where a bundler adds nothing, but it does NOT kill tsc, checking simply moves wholly to the editor and CI. Teams adopting it typically flip on erasableSyntaxOnly, sweep enums to as-const objects, and delete their dev-runner dependency (tsx/ts-node) in the same PR.

// Runs directly with: node src/worker.ts (recent Node)
import type { Job } from './types.ts';   // type import: stripped cleanly

const RETRY_LIMITS = { email: 3, webhook: 5 } as const;  // enum replacement
type Channel = keyof typeof RETRY_LIMITS;

export async function process(job: Job, channel: Channel): Promise<void> {
  for (let i = 0; i < RETRY_LIMITS[channel]; i++) {
    if (await attempt(job)) return;
  }
}

// tsconfig for a stripping-compatible codebase
// {
//   "compilerOptions": {
//     "erasableSyntaxOnly": true,       // bans enum/namespace/param props
//     "verbatimModuleSyntax": true,
//     "allowImportingTsExtensions": true,
//     "noEmit": true
//   }
// }

Key Points

  • Stripping replaces types with whitespace: no checking, no codegen
  • Enums, value namespaces, parameter properties are not erasable
  • erasableSyntaxOnly (TS 5.8) makes the checker enforce strippability
  • tsc's job moves entirely to editor + CI; the runtime never checks
Q47

Why does Object.keys return string[] instead of keyof T, and what are the safe patterns for iterating typed objects?

IntermediateType System

Answer

This question looks like trivia and is actually about soundness, which is why interviewers love it. Object.keys(obj) is typed string[] deliberately: structural typing means a value of type T may have MORE properties than T declares (any wider object is assignable), so claiming the keys are exactly keyof T would be a lie the type system cannot back. Given function f(shape: { x: number }), callers can pass { x: 1, debugLabel: 'p' }; inside f, Object.keys returns ['x', 'debugLabel'], and if it were typed ('x')[], indexed reads through those keys would be typed number while one of them is a string at runtime.

The same reasoning types for-in keys as string, and Object.entries values conservatively. The safe patterns, in order of preference: iterate ENTRIES of data you constructed locally from a closed Record type, where a cast (Object.keys(o) as (keyof typeof o)[]) is pragmatically fine because you control construction, the standard, honest workaround, ideally wrapped in a single typedKeys helper so the unsoundness lives in one reviewed place; restructure to avoid key iteration entirely, iterating a declared list of keys (const KEYS = ['x', 'y'] as const) and reading properties through it, which keeps everything sound and survives refactors via compile errors; or use a Map when keys are genuinely dynamic. Related follow-ups to be ready for: why obj[key] errors with 'implicit any' when key is string (index signatures absent, use keyof-typed keys or a type guard on the key with the in operator, which since TS 4.9 narrows the KEY, not just the object); and why the unsound-looking cast is nonetheless ubiquitous, because the alternative is friction with zero practical failures when the object never crosses a structural widening boundary. Articulating that trade-off, soundness rule, why it exists, when the escape hatch is acceptable, is exactly the judgment being screened.

const limits = { free: 10, gold: 100, platinum: 1000 } as const;

// Honest helper: one reviewed unsoundness instead of scattered casts
function typedKeys<T extends object>(o: T): (keyof T)[] {
  return Object.keys(o) as (keyof T)[];
}

for (const plan of typedKeys(limits)) {
  console.log(plan, limits[plan]);   // plan: 'free' | 'gold' | 'platinum'
}

// Fully sound alternative: iterate a declared key list
const PLANS = ['free', 'gold', 'platinum'] as const;
for (const plan of PLANS) console.log(limits[plan]);

// 'in' narrows the key since TS 4.9
function read(o: typeof limits, key: string) {
  if (key in o) return o[key as keyof typeof o]; // cast still needed pre-narrowing patterns
  return undefined;
}
Q48

How do you type 'this' in TypeScript: this parameters, ThisParameterType, and this-based fluent APIs?

IntermediateFunctions

Answer

JavaScript's this is call-site-determined, and TypeScript models it with a pseudo-parameter: a function may declare this as its FIRST parameter (function handler(this: HTMLButtonElement, e: Event)), which is erased from the emitted code and from the argument list, but checked at every call: invoking the function with a mismatched receiver (calling it unbound, or via .call with the wrong object) is a compile error, provided noImplicitThis (part of strict) is on; without an annotation in a context the compiler cannot infer, this silently types as any. The utilities ThisParameterType<T> extracts that declared receiver type and OmitThisParameter<T> removes it, which is how bind's typing works: bound functions lose their this requirement. Where this typing earns its keep in real code: extracting a method into a callback (const fn = obj.method passes type checking but detaches this at runtime, the unbound-method lint rule plus a this parameter turn this into a compile-time error); event-handler APIs where the library sets the receiver (jQuery-era patterns, Node's EventEmitter internals, Mongoose middleware where this is the document, all typed with this parameters in their .d.ts); and object literals with methods, where ThisType<T> (a marker interface used in the object-literal contextual type) tells the compiler what this means inside, the mechanism Vue's Options API used to type component methods against data and computed without any explicit annotations.

The fluent-API pattern rounds it out: a method declared to return this (the polymorphic this type) returns the ACTUAL subclass type, so class QueryBuilder { where(): this } chains correctly even from class PgQueryBuilder extends QueryBuilder, each link preserving the most-derived type; returning the class name instead of this would lock chains to the base class. Arrow functions, as in JavaScript, capture lexical this and cannot declare a this parameter, which is precisely why class-property arrows are the standard fix for callback receivers.

class QueryBuilder {
  protected clauses: string[] = [];
  where(cond: string): this {           // polymorphic 'this' return
    this.clauses.push(cond);
    return this;
  }
}
class PgQueryBuilder extends QueryBuilder {
  forUpdate(): this { this.clauses.push('FOR UPDATE'); return this; }
}
new PgQueryBuilder().where('id = $1').forUpdate(); // chain keeps subclass type

// 'this' parameter: unbound use becomes a compile error
function onClick(this: HTMLButtonElement, e: Event) {
  this.disabled = true;
}
button.addEventListener('click', onClick);   // OK: receiver matches
// setTimeout(onClick, 100)  -> Error: 'this' of type void not assignable

type Receiver = ThisParameterType<typeof onClick>; // HTMLButtonElement
const detached: OmitThisParameter<typeof onClick> = onClick.bind(button);
Q49

Recursive conditional types and variadic tuples: build Flatten, tuple Reverse, and explain the recursion limits you will hit.

AdvancedType-Level Programming

Answer

TypeScript 4.1 allowed conditional types to reference themselves, and combined with variadic tuple patterns ([infer Head, ...infer Tail]) this gives the type system structural recursion: process the head, recurse on the tail. The canonical exercises interviewers set: Flatten<T> unwrapping nested arrays (T extends (infer U)[] ? Flatten<U> : T); Reverse<T> for tuples, moving the head to the end each step; type-level Join/Split over template literals; and DeepPartial/DeepReadonly walking object graphs.

Writing them is mechanical once you internalise the pattern, so the differentiating knowledge is the LIMITS. The compiler caps type-instantiation depth (the historical limit is around 50 levels for eagerly-evaluated recursion, roughly 1000 for tail-recursive evaluation since TS 4.5's tail-recursion elimination for conditional types), producing the errors 'Type instantiation is excessively deep and possibly infinite' (ts2589) or, for exploding unions, 'union type that is too complex to represent'. The tail-recursion optimisation matters practically: an accumulator-style Reverse<T, Acc extends unknown[] = []> where the recursive call IS the branch result gets the deep limit, while a version that wraps the recursive result in more structure ([...Reverse<Tail>, Head]) does not, so refactoring type recursion into accumulator form is a genuine optimisation technique, the type-level analogue of converting to tail calls. Also load-bearing: recursion over long literal strings (Split on a 2,000-character template) and over large tuples is quadratic-ish in checker work and can single-handedly wreck editor latency; depth limits are per-instantiation, so caching via named intermediate type aliases can help the checker reuse results; and ts2589 in application code is usually a smell that the type should be simplified or the transformation moved to codegen (generating .d.ts from a schema at build time), which is what mature codebases do when ORM or router types start timing out the language server.

type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T;
type F = Flatten<number[][][]>;         // number

// Accumulator form: eligible for tail-recursion elimination (TS 4.5+)
type Reverse<T extends readonly unknown[], Acc extends unknown[] = []> =
  T extends readonly [infer H, ...infer Rest]
    ? Reverse<Rest, [H, ...Acc]>
    : Acc;
type R = Reverse<[1, 2, 3]>;            // [3, 2, 1]

type Split<S extends string, Sep extends string> =
  S extends `${infer Head}${Sep}${infer Tail}`
    ? [Head, ...Split<Tail, Sep>]
    : [S];
type Parts = Split<'a.b.c', '.'>;       // ['a', 'b', 'c']

// This is where ts2589 lives:
// type Boom = Reverse<BuildTuple<5000>> // excessively deep
Q50

Branded (nominal) types: how do you stop a UserId being passed where an OrderId is expected, and where do brands sit in a real codebase?

AdvancedType Design

Answer

Structural typing makes every string interchangeable with every other string, which is exactly wrong for identifiers, currencies, and validated values: swapping userId and orderId arguments compiles cleanly and corrupts data at runtime. Branding manufactures nominal behaviour inside the structural system: intersect the base type with a marker property that exists only at the type level, type UserId = string & { readonly __brand: 'UserId' }. No runtime value ever has __brand, and none needs to: plain strings are no longer assignable to UserId (the intersection demands the marker), while UserId still IS a string wherever a string is expected, so branded values flow into logging, template literals, and Map keys freely; the constraint is one-directional by design.

Construction goes through a narrow gate: a cast at the boundary (id as UserId) wrapped in a constructor function that optionally VALIDATES before casting, which upgrades brands from documentation to proof-carrying types: type Email = string & Brand<'Email'> plus const parseEmail = (s: string): Email | null means an Email-typed value anywhere downstream certifies it passed validation once, the 'parse, don't validate' principle expressed in TypeScript. Implementation refinements that come up: use a unique symbol as the brand key so brands from different libraries cannot collide accidentally; a generic Brand<T, Name> helper keeps declarations one-liners; Zod supports .brand() so schema inference emits branded types directly; and never let the brand property become real (no runtime writes), or JSON serialisation and structuredClone semantics stay unaffected. Costs to acknowledge: every construction site needs the gate (ergonomic friction, especially in tests, where a makeUserId fixture helper is standard); brands do not survive JSON round-trips without re-parsing; and over-branding primitives that never cross wires (loop counters, local strings) is noise. The sweet spot, and the answer interviewers want: brand identifiers that cross service or table boundaries, money (paise vs rupees as distinct brands has prevented real billing bugs), and validated user input; leave everything else structural.

declare const brand: unique symbol;
type Brand<T, Name extends string> = T & { readonly [brand]: Name };

type UserId  = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Paise   = Brand<number, 'Paise'>;

const asUserId  = (s: string): UserId  => s as UserId;   // boundary gate
const toPaise   = (rupees: number): Paise => Math.round(rupees * 100) as Paise;

function refund(user: UserId, order: OrderId, amount: Paise) { /* ... */ }

const u = asUserId('u_91');
const o = 'ord_17' as OrderId;
refund(u, o, toPaise(499));
// refund(o, u, toPaise(499))
// Error: OrderId not assignable to UserId, the swap is now impossible

console.log(`refunding ${u}`);   // still a string where it matters
Q51

Distributive conditional types: why does Exclude work, when does distribution ruin your type, and how do you switch it off?

AdvancedType-Level Programming

Answer

When the type being tested in T extends U ? X : Y is a NAKED type parameter (T itself, not [T] or T[] or a template around it), instantiating T with a union distributes the conditional over each member and unions the results: Exclude<'a' | 'b' | 'c', 'b'> evaluates the conditional three times and reassembles 'a' | 'c'. This is the engine behind Exclude, Extract, NonNullable, and every 'filter a union' utility, and it is usually what you want.

It becomes a trap in three recognisable shapes. First, boolean-producing types: IsString<string | number> distributes into true | false (i.e. boolean), when the intended question was 'is this exact type a string', with the answer false. Second, wrapping unions: ToArray<string | number> distributes to string[] | number[], whereas you may have wanted (string | number)[], a heterogeneous array, and these are very different types to callers.

Third, never vanishing: never is the empty union, so a distributive conditional over never produces never WITHOUT evaluating either branch, which silently breaks IsNever<T> written naively and any utility that must react to never specifically. The off-switch is breaking nakedness, conventionally by wrapping both sides in a one-element tuple: [T] extends [U] ? X : Y compares the whole union at once, and [T] extends [never] correctly detects never.

Related craft knowledge that distinguishes a senior answer: distribution happens per INSTANTIATION, so a nested conditional distributes at each naked-parameter level, and deliberately re-enabling distribution inside a helper is done by the idiom T extends unknown ? F<T> : never (a no-op test whose only job is to distribute F over the members), which is exactly how DistributiveOmit and mapped-over-union utilities are built. Boolean logic at the type level (And, Or, Not) requires the tuple-wrapped forms throughout or the intermediate booleans dissolve into boolean and destroy the computation.

type Exclude_<T, U> = T extends U ? never : T;         // distributes: the point
type Kept = Exclude_<'a' | 'b' | 'c', 'b'>;             // 'a' | 'c'

type ToArrayDist<T>    = T extends unknown ? T[] : never;
type ToArrayWhole<T>   = [T] extends [unknown] ? T[] : never;
type A1 = ToArrayDist<string | number>;   // string[] | number[]
type A2 = ToArrayWhole<string | number>;  // (string | number)[]

// never is the empty union: distribution yields never, skipping both branches
type IsNeverWrong<T> = T extends never ? true : false;
type W = IsNeverWrong<never>;             // never (!), not true
type IsNever<T> = [T] extends [never] ? true : false;
type C = IsNever<never>;                  // true

// Deliberate re-distribution: apply Omit per union member
type DistributiveOmit<T, K extends PropertyKey> =
  T extends unknown ? Omit<T, K> : never;
Q52

Design a fully type-safe event emitter where each event name is bound to its payload type. What makes this hard?

AdvancedType Design

Answer

The specification: emitter.on('payment.captured', handler) must type handler's parameter as the captured-payment payload, emitter.emit must demand the right payload per name, unknown event names must not compile, and adding an event to the map must light up every incomplete call site. The architecture is an EVENT MAP interface, keys are event names, values are payload types, threaded through a generic class: class TypedEmitter<Events extends Record<string, unknown>> with methods generic over K extends keyof Events, on<K>(name: K, fn: (payload: Events[K]) => void) and emit<K>(name: K, payload: Events[K]). The indexed access Events[K] is the whole trick: one type parameter links name to payload.

What makes it genuinely hard, and what interviewers dig into: internal storage cannot maintain the per-key relationship, a Map<keyof Events, Set<(p: Events[keyof Events]) => void>> types every stored handler as accepting the UNION of payloads, and under strictFunctionTypes (contravariance) a handler for one specific payload is NOT assignable to that union-accepting type, so the class interior needs a controlled cast, the honest observation being that the public surface is fully sound while the implementation hides one unsoundness in a private, reviewed spot, a pattern general to typed wrappers over heterogeneous storage. Extensions that demonstrate depth: wildcard subscriptions typed via distributive tricks (on('payment.*') matching `payment.${string}` keys with template-literal filtering); once() returning a promise typed Promise<Events[K]>; typing Node's EventEmitter retroactively by declaration-merging an interface with overloaded on/emit signatures (how @types packages and typed-emitter historically did it); and acknowledging library precedents, mitt is trivially generic this way, and Node's events module added generic event-map parameters to EventEmitter in recent versions, so rolling your own is now mostly an interview exercise. The follow-up trap to anticipate: making Events a class-level parameter but forgetting to constrain handler storage per key, which surfaces as 'why does emit accept the wrong payload', the answer being an accidental keyof-union collapse somewhere in the chain.

type Events = {
  'payment.captured': { orderId: string; paise: number };
  'payment.failed':   { orderId: string; reason: string };
  'user.deleted':     { userId: string };
};

class TypedEmitter<E extends Record<string, unknown>> {
  private handlers = new Map<keyof E, Set<(p: never) => void>>();

  on<K extends keyof E>(name: K, fn: (payload: E[K]) => void): () => void {
    const set = this.handlers.get(name) ?? new Set();
    set.add(fn as (p: never) => void);        // the one interior cast
    this.handlers.set(name, set);
    return () => set.delete(fn as (p: never) => void);
  }

  emit<K extends keyof E>(name: K, payload: E[K]): void {
    this.handlers.get(name)?.forEach(fn => (fn as (p: E[K]) => void)(payload));
  }
}

const bus = new TypedEmitter<Events>();
bus.on('payment.failed', p => queueRetry(p.orderId, p.reason)); // p typed
// bus.emit('payment.failed', { orderId: 'o1' })
// Error: 'reason' is missing, payload checked per event name
Q53

What do the in/out variance annotations on type parameters do, and when should a library author use them?

AdvancedType System

Answer

TypeScript infers variance structurally: to decide whether Producer<Dog> is assignable to Producer<Animal>, the checker examines how T is USED inside Producer, output positions make it covariant, input positions contravariant, both make it invariant. TS 4.7 added explicit annotations: interface Producer<out T>, interface Consumer<in T>, interface State<in out T>, declaring the intended variance instead of leaving it inferred. They serve two distinct purposes, and knowing both is the senior answer.

Purpose one, correctness documentation: the compiler VERIFIES the annotation against actual usage, so marking a parameter out and then adding a method that takes T is a compile error at the declaration site rather than a silent variance change that breaks downstream assignability in confusing ways; annotations turn 'we accidentally made this invariant in a refactor' from an ecosystem incident into a local error. Purpose two, checker performance and circularity: variance MEASUREMENT on deeply recursive generic types (ORM entity graphs, deeply nested builder types) can be expensive or even inconclusive, forcing the checker into slower structural comparisons; explicit annotations let it skip measurement, which shipped real speedups in heavily generic libraries, and can also break unresolvable circular-variance situations. When NOT to use them: application code, where inference is nearly always right and annotations add ceremony; and never to LIE, annotating out on a parameter genuinely used contravariantly is rejected, but subtler mismatches around methods (which check bivariantly) can make an annotation paper over an unsoundness, so they must state the truth, not aspiration.

Adjacent facts worth one sentence each in an interview: variance explains why Array<Dog> assignable to Array<Animal> is unsound-but-allowed (methods check bivariantly by design), why ReadonlyArray IS soundly covariant (no input positions), and why Map keys are invariant. The annotations use the same in/out vocabulary as Kotlin and C#, which is a useful anchor if the panel spans languages.

interface Producer<out T> {
  next(): T;                 // T only in output position: OK
  // feed(item: T): void;    // would ERROR: 'in' usage under 'out' annotation
}

interface Consumer<in T> {
  accept(item: T): void;
}

declare const dogSource: Producer<Dog>;
const animalSource: Producer<Animal> = dogSource;   // covariant: fine

declare const animalSink: Consumer<Animal>;
const dogSink: Consumer<Dog> = animalSink;          // contravariant: fine

interface Cell<in out T> {         // invariant: both directions rejected
  get(): T;
  set(v: T): void;
}
Q54

The type checker has become your slowest tool: how do you diagnose and fix TypeScript compile-time performance, and what changes with the Go-native compiler?

AdvancedBuild & Scale

Answer

Treat checker slowness like any perf problem: measure, attribute, fix the top offender. Measurement tools in order of escalation: tsc --extendedDiagnostics prints phase timings, instantiation counts, and memory (a healthy codebase has type instantiations in the low millions; hundreds of millions means a type bomb); tsc --generateTrace out produces a Chrome-traceable profile plus a types.json that the @typescript/analyze-trace package summarises into 'these files/types cost the most', pointing at the exact expensive instantiations; and for editor pain specifically, the TS server logs show which requests stall. The recurring culprits are remarkably consistent across codebases: giant unions (thousands-member string-literal unions from generated code, or template-literal cross products); deeply recursive conditional types in hot positions (ORM types are notorious, deeply generic Prisma/Drizzle client types instantiating per call site); variance measurement on recursive generics (fix with explicit in/out annotations); repeated re-checking of the same huge anonymous types (fix by NAMING intermediate types, interfaces are cached by identity, and preferring interface extends over intersections, which re-compose every time); and barrel files plus missing skipLibCheck multiplying declaration-checking work.

Structural fixes when micro-fixes run out: project references so unchanged subgraphs are not re-checked, incremental with cached .tsbuildinfo in CI, isolatedDeclarations to make declaration emit parallelisable, and moving type-level computation to build-time codegen. The 2026 horizon: Microsoft's Go-native compiler port (tsgo, the TypeScript 7 track, preview-published as @typescript/native-preview) reimplements the checker with real parallelism and reports roughly 10x faster full checks on large repos, with the JS implementation continuing as the 6.x line during transition. The judgment call interviewers probe: native speed raises the ceiling but does not repeal complexity, a type bomb that is 10x faster is still a bomb in the editor's inner loop, so trace-driven simplification of the worst types remains the durable skill.

# Attribute the cost
npx tsc --noEmit --extendedDiagnostics
#   Types: 1_842_003   Instantiations: 214_770_112   <- bomb detected
npx tsc --noEmit --generateTrace trace_out
npx @typescript/analyze-trace trace_out
#   -> 'Expression produces a union type of 41,912 members' in query.ts

// Fix pattern 1: name and cache the expensive type once
type OrderQuery = BuildQuery<OrderTable>;   // alias evaluated once, reused

// Fix pattern 2: interface extends instead of repeated intersections
interface AuditedOrder extends Order, AuditFields {}

# Try the native compiler preview on the same repo
npx @typescript/native-preview --noEmit

Key Points

  • extendedDiagnostics for triage, generateTrace + analyze-trace for attribution
  • Usual suspects: huge unions, recursive ORM types, unnamed intersections
  • Name intermediate types; prefer interface extends; annotate variance
  • tsgo (TypeScript 7) parallelises checking ~10x; complexity still matters
Q55

What does isolatedDeclarations require, and how does it change publishing a typed library?

AdvancedBuild & Scale

Answer

Declaration emit (.d.ts generation) traditionally requires the FULL type checker: to write the declaration for export const client = createClient(config), the compiler must infer the return type, which may pull in half the program. That makes declaration emit the un-parallelisable, un-cacheable step in monorepo builds, every package's d.ts depends on type checking everything beneath it. isolatedDeclarations (TS 5.5) flips the contract: with the flag on, every exported binding must have a type derivable from the FILE ALONE, explicit return types on exported functions, explicit types on exported constants whose initialisers need inference, no exported spread-of-imported-value tricks. Violations are compile errors with quick-fixes that insert the inferred annotation.

The payoff is that declaration emit becomes a local, syntactic transformation any tool can do without a checker: swc and esbuild-adjacent emitters can generate d.ts in parallel per file, and monorepo builds stop serialising on tsc. Publishing mechanics this connects to: a 2026 library ships an exports map with types conditions per entry point, declarations generated with declaration + declarationMap (so go-to-definition reaches source), validated with arethetypeswrong for resolution bugs and with api-extractor or a d.ts snapshot diff for accidental breaking changes; publint checks packaging hygiene. The design consequence worth stating: isolatedDeclarations pushes library authors toward EXPLICIT public contracts, which independently improves API review (a PR touching a return annotation is visibly a contract change) and protects downstream users from inference drift, the same philosophy as annotating exported functions, now compiler-enforced.

Trade-offs: annotation burden on heavily generic internal helpers that happen to be exported (often a sign they should not be exported), and some patterns (classes extending expressions, complex const inference) need restructuring. Adoption is therefore easiest on NEW packages and on the leaf-most shared libraries of a monorepo, where the build-parallelism payoff is also largest.

// tsconfig: { "isolatedDeclarations": true, "declaration": true }

import { createClient } from './internal/factory';

// Error under isolatedDeclarations: return type requires inference
// export const client = createClient({ region: 'ap-south-1' });

// Compliant: the contract is explicit and file-local
export const client: SearchClient = createClient({ region: 'ap-south-1' });

export function score(candidate: Candidate, job: Job): MatchScore {
  return computeScore(candidate, job);
}
// Every exported signature readable without running the checker:
// d.ts emit is now parallel-friendly and tool-agnostic (swc can do it)
Q56

TC39 standard decorators vs experimentalDecorators: what changed, and how do you write a modern class decorator?

AdvancedLanguage Evolution

Answer

TypeScript carried experimental decorators (the old experimentalDecorators flag, based on a 2015-era proposal) for a decade, and Angular, NestJS, TypeORM, and class-validator built empires on them, typically together with emitDecoratorMetadata and reflect-metadata for runtime type reflection (design:paramtypes powering DI containers). TC39 then standardised a DIFFERENT decorators design, which TypeScript implements natively since 5.0 when experimentalDecorators is OFF. The differences are structural, not cosmetic.

Standard decorators receive (value, context) where context is an object ({ kind: 'method' | 'class' | 'field' | 'accessor' | 'getter' | 'setter', name, static, private, addInitializer }), instead of the legacy (target, propertyKey, descriptor) trio; field decorators return an initialiser-transforming function rather than mutating a descriptor; the new accessor keyword creates decoratable auto-accessors backed by private storage; parameter decorators DO NOT EXIST in the standard (a direct problem for NestJS-style @Body()/@Param() and constructor-injection tokens); and there is no standardised metadata emission, the Symbol.metadata proposal fills part of that gap via context.metadata, but the automatic design-time type metadata that emitDecoratorMetadata provided has no standard equivalent, which is why DI frameworks cannot silently migrate. Consequently the ecosystem runs two worlds in 2026: NestJS/Angular/TypeORM codebases keep experimentalDecorators on (both flags cannot apply to the same class semantics, the flag globally selects the mode), while new libraries target standard decorators, and Vite/esbuild/swc all transform the standard form. Also relevant: legacy decorators are non-erasable syntax under Node's type stripping, one more force pushing new code toward the standard form or away from decorators entirely. A strong interview answer writes a standard method decorator from memory (logging or memoisation via (value, context) wrapping), mentions addInitializer for per-instance setup like auto-binding, and can articulate exactly WHY NestJS cannot just flip the flag: parameter decorators and reflected parameter types are load-bearing there.

// Standard (TC39) decorators: TS 5.0+, experimentalDecorators OFF
function logged<This, Args extends unknown[], R>(
  value: (this: This, ...args: Args) => R,
  context: ClassMethodDecoratorContext<This>,
) {
  const name = String(context.name);
  return function (this: This, ...args: Args): R {
    console.time(name);
    try { return value.call(this, ...args); }
    finally { console.timeEnd(name); }
  };
}

function bound<This>(_v: unknown, ctx: ClassMethodDecoratorContext<This>) {
  ctx.addInitializer(function (this: This) {
    (this as Record<string, unknown>)[String(ctx.name)] =
      (this as never)[ctx.name as never].bind(this);
  });
}

class ReportService {
  @logged
  generate(month: string) { /* ... */ }

  @bound
  onClick() { /* safe to pass as a callback */ }

  accessor status: 'idle' | 'running' = 'idle'; // decoratable auto-accessor
}
Q57

How do you structure TypeScript in a monorepo: tsconfig layering, internal packages, and the paths-alias trap?

AdvancedBuild & Scale

Answer

The recurring decisions: how packages reference each other's types, how many tsconfigs exist, and what the editor versus CI actually check. Config layering first: a root tsconfig.base.json holds shared compilerOptions (strict family, target, moduleResolution), each package extends it and owns only its include, outDir, and references; TS 5.0 supports extending arrays of configs for composing option sets. Cross-package type flow has three viable strategies.

One, 'internal packages': each package's package.json exports point at SOURCE (./src/index.ts) in development via conditions, and the consuming app's bundler compiles everything; simplest DX, instant cross-package edits, but tsc in one package re-checks dependencies' source, so check times grow with the graph, fine up to mid-size repos and the default in many Turborepo setups. Two, compiled packages with project references and tsc -b: consumers see .d.ts, checks are incremental and parallel-friendly, at the cost of build orchestration and 'stale declarations' confusion when someone edits core without rebuilding (declarationMap plus an always-on watch build mitigates). Three, per-package tsc --noEmit orchestrated by Nx/Turborepo with content-hash caching, treating type checking as just another cached task; this is the pragmatic winner in many large 2026 repos because remote caching makes warm CI checks near-instant without tsc build-mode ceremony.

The paths-alias trap deserves its own paragraph: compilerOptions.paths only affects TYPE RESOLUTION, it does not rewrite emitted imports, so aliased imports that tsc emits verbatim crash at runtime unless the bundler (or tsconfig-paths at runtime, or tsc-alias post-build) resolves the same aliases; the modern recommendation is to prefer real workspace package names (@goodspace/core via workspace protocol) over bare path aliases, since package resolution works identically for tsc, bundlers, Node, and test runners with zero re-mapping. Finish with hygiene: one TypeScript VERSION for the whole repo (multiple versions make the editor's choice ambiguous and .tsbuildinfo incompatible), skipLibCheck on, and a repo-wide check script that CI and pre-merge both run.

// tsconfig.base.json (root)
{
  "compilerOptions": {
    "strict": true,
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "skipLibCheck": true,
    "noUncheckedIndexedAccess": true
  }
}

// packages/core/package.json: real package name beats paths aliases
{
  "name": "@goodspace/core",
  "exports": {
    ".": {
      "development": "./src/index.ts",   // editors + dev bundler see source
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}

# turbo.json task graph: cached per-package checking
# "check-types": { "dependsOn": ["^check-types"], "outputs": [] }
npx turbo run check-types   # warm cache -> seconds, not minutes
Q58

You inherit a 300k-line JavaScript codebase. Lay out a migration to TypeScript that never blocks feature work.

AdvancedMigration Strategy

Answer

The failure mode to design against is the big-bang branch that drifts for a quarter and dies. The workable plan is ratchet-based: TypeScript and JavaScript coexist, strictness only ever increases, and every step ships to production. Phase zero: introduce tsc with allowJs: true and checkJs: false, noEmit (the bundler keeps building as before), so .ts files become POSSIBLE without touching .js behaviour; add tsconfig, CI check, and editor consistency in one small PR.

Phase one: boundaries first, hand-write or generate .d.ts/types for the seams (API responses via OpenAPI or Zod schemas, shared constants, the data layer), because types at module boundaries multiply value across every file that imports them, unlike leaf-file conversions. Phase two: the ratchet. New files must be .ts (enforced with a lint rule or a simple CI file-extension check); converted files must pass strict even while the global config stays loose, achievable per-directory with nested tsconfigs or with a tool like betterer/type-coverage tracking that the ANY-count and error-count only decrease; convert opportunistically, when a file is touched for feature work, migrate it in the same PR while context is loaded.

High-leverage mechanical passes worth doing early: rename obvious leaf utilities, enable checkJs with @ts-check JSDoc on stable files to harvest cheap errors without renaming, and run codemods (ts-migrate-style) for mass annotation insertion where inference cannot help, accepting temporary any markers that the ratchet then burns down. Sequencing strictness matters: noImplicitAny before strictNullChecks (null-safety on a half-typed graph produces avalanche errors), suppressions via one-line @ts-expect-error WITH a ticket reference rather than @ts-ignore, so every debt item is countable and greppable. Track and publicise metrics, percent files typed, any-count, errors suppressed, because migration stalls are political before they are technical. Expect the long tail (complex reducers, dynamic metaprogramming, monkey-patched modules) to take longest; it is acceptable for the last few percent to stay JSDoc-typed JavaScript indefinitely, the codebase gets ninety-five percent of the value without the last five percent of the pain.

// tsconfig.json at migration start
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,        // flips true per-directory as areas stabilise
    "noEmit": true,
    "strict": false,
    "noImplicitAny": true    // the first ratchet
  },
  "include": ["src"]
}

// Boundary-first: typing the seam pays across the whole graph
// src/api/contracts.d.ts
export interface JobPosting {
  id: string;
  title: string;
  salaryBand?: { minLpa: number; maxLpa: number };
}

// Cheap wins inside untouched .js files
// @ts-check
/** @param {JobPosting} job @returns {string} */
export function formatSalary(job) {
  return job.salaryBand ? `${job.salaryBand.minLpa}-${job.salaryBand.maxLpa} LPA` : 'Not disclosed';
}

Key Points

  • allowJs coexistence + ratchet: strictness only increases, always shippable
  • Type boundaries (API, data layer) before leaves: value multiplies
  • noImplicitAny before strictNullChecks; @ts-expect-error with tickets
  • Track any-count/error-count publicly; the last 5% can stay JSDoc
Q59

How do tRPC-style frameworks achieve end-to-end type safety without code generation, and what are the limits of the approach?

AdvancedArchitecture

Answer

The trick is that the server's router value has a TYPE that encodes every procedure name, input schema, and output type, and the client imports ONLY that type: import type { AppRouter } from '../server/router'. Because type-only imports are fully erased, no server code reaches the client bundle; the client is a Proxy that turns property access paths (client.orders.byId.query) into HTTP calls, while the imported router type makes TypeScript resolve each path to its precise input and output types via indexed access and inference. Input schemas (Zod or any Standard Schema validator) do double duty: runtime validation on the server and static input types on the client through z.infer, so the contract is one artifact, and renaming a procedure or changing its output breaks the CLIENT'S compilation immediately, refactoring across the network boundary behaves like refactoring within one program.

This monorepo-shaped magic defines its limits, which is where senior discussion happens. It requires the client to see server types at build time: same repo or a published types package, so it fits internal products and breaks down for public APIs and polyglot consumers, where schema-first contracts (OpenAPI with generated clients via openapi-typescript/orval, or GraphQL codegen, or gRPC/Protobuf) remain correct because the contract must outlive any one implementation and serve non-TypeScript clients. Versioning is harder: a deployed mobile app compiled against last month's router type still calls today's server, so 'compile-time safety' does not absolve you of wire-level compatibility discipline; additive evolution and runtime validation of outputs at sensitive boundaries stay necessary.

And checker cost is real: very large routers produce heavy type instantiation (the classic 'IDE slow in the api package' complaint), mitigated by splitting routers and explicit output types. The comparative framing interviewers want: tRPC-style inference maximises velocity inside a TypeScript monolith-with-edges; contract-first generation maximises interoperability and independent evolution; server frameworks like Hono blur the line by exporting inferable app types for their RPC clients while remaining plain HTTP underneath.

// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();

export const appRouter = t.router({
  orders: t.router({
    byId: t.procedure
      .input(z.object({ id: z.string() }))
      .query(({ input }) => getOrder(input.id)), // return type inferred
  }),
});
export type AppRouter = typeof appRouter;   // ONLY this crosses to the client

// client/api.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router'; // erased at build time

const api = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: '/trpc' })],
});

const order = await api.orders.byId.query({ id: 'ord_17' });
// order: Awaited<ReturnType<typeof getOrder>>, renames break this line
Q60

Use phantom type parameters to make an API impossible to misuse: design a query builder whose execute() only compiles when required steps were called.

AdvancedType Design

Answer

A phantom type parameter appears in a type's signature but corresponds to no runtime data; it exists purely to thread compile-time STATE through an API, the type-state pattern. The query-builder brief: from() must be called before execute(), where() is optional, and calling execute() on an incomplete builder should be a compile error rather than a runtime throw. Encode builder state as a type parameter: class Query<State extends 'empty' | 'ready'>, with from() declared on Query<'empty'> returning Query<'ready'>, and execute() declared so it EXISTS only on Query<'ready'>.

Two implementation strategies exist. The class-based one uses this-typing constraints: execute(this: Query<'ready'>), a mis-staged call fails with 'this context not assignable'. The more flexible functional one returns progressively wider INTERFACE types: each step returns a plainly different type that simply lacks the methods that are not yet legal, giving perfect autocomplete (illegal methods do not even appear) and clearer errors; the runtime object can be one mutable instance underneath, with the types acting as a shrinking mask, one honest interior cast per transition.

Real-world incarnations to cite: ORM query builders (Kysely is the flagship example of type-state driven SQL building in TypeScript, its joins and selections literally reshape the result type), fluent HTTP/request builders that require auth before send, state machines where transition methods exist only in valid states (XState v5's setup API pushes typing this direction), and safe-by-construction workflow objects (a Payment that must pass authorize() before capture() exists). Design costs to acknowledge like a senior: error messages reference type-state names, so name the states readably ('MissingFrom' beats 'S0'); conditional accumulation (tracking WHICH columns were selected in a growing tuple) can hit checker-cost issues at scale; storing partially-built builders in variables requires writing the intermediate types, so keep them exported and nameable; and the pattern guards the COMPILE boundary only, inputs arriving at runtime still need validation, phantom types complement, not replace, the Zod layer at ingress.

type Empty = { from<T extends TableName>(table: T): Ready<T> };
type Ready<T extends TableName> = {
  where(cond: WhereClause<T>): Ready<T>;
  limit(n: number): Ready<T>;
  execute(): Promise<Row<T>[]>;
};

function createQuery(): Empty {
  const state = { table: '', wheres: [] as string[], limit: 0 };
  const ready = {
    where(c: string) { state.wheres.push(c); return ready; },
    limit(n: number) { state.limit = n; return ready; },
    execute() { return runSql(state); },
  };
  return {
    from(table: string) { state.table = table; return ready; },
  } as Empty; // one reviewed interior cast; the surface is fully staged
}

const q = createQuery();
// q.execute()            -> compile error: execute does not exist yet
// q.where('x = 1')       -> compile error: where before from
const rows = await createQuery().from('orders').where('paid = true').execute();

Companies Hiring TypeScript

Microsoft
Razorpay
CRED
Flipkart
Swiggy
Postman
Atlassian
Zerodha

Salary Insights

Average in India
₹7-24 LPA

Frequently Asked Questions

How much does a TypeScript developer earn in India in 2026?

TypeScript is usually priced as part of a frontend, backend, or full-stack role rather than as a standalone skill, and those roles land around ₹7-24 LPA for mid-level engineers. Freshers at service companies (TCS, Infosys, Wipro) start near ₹4-7 LPA, while product companies and fintech (Razorpay, CRED, Zerodha, Flipkart, Swiggy) pay ₹12-25 LPA at 2-4 years of experience for engineers who are genuinely strong in TypeScript plus React or Node. Senior and staff engineers who can own typed architecture across a monorepo, design libraries with clean declaration output, and mentor teams on strictness migrations cross ₹35-60 LPA at top product companies and global capability centres. Remote-first international employers hiring from India often benchmark higher still.

How long does it take to prepare for a TypeScript interview?

If you already write JavaScript daily, two to three weeks of focused preparation covers the interview surface: one week on fundamentals (structural typing, narrowing, strict flags, generics), one week on the intermediate layer (utility types, conditional and mapped types, discriminated unions, tsconfig and module resolution), and a few days rebuilding the standard utility types from scratch, which is the single most commonly asked whiteboard exercise. If you are new to static typing entirely, budget six to eight weeks and write a real project under strict: true rather than only reading, most TypeScript interview failures are people who can define a concept but freeze when asked to fix a concrete compiler error live. For senior roles, add time for compiler performance, publishing, and migration war stories, because those rounds are experience-driven.

What do interviewers expect from freshers vs experienced candidates in TypeScript?

Freshers are tested on fundamentals: interface vs type, any vs unknown, narrowing, enums and their alternatives, generics on simple functions, and reading compiler errors calmly. Being able to explain WHY a given error appears is worth more than memorised definitions. At 2-5 years, expect utility types, discriminated unions with exhaustiveness, generic constraints, tsconfig decisions (strict family, module resolution), runtime validation with Zod, and testing setups where transpilers skip type checking. Senior candidates get architecture and judgment questions: designing type-safe APIs, variance, compiler performance triage, monorepo build strategy, migration planning for large JavaScript codebases, and the trade-offs of inference-based end-to-end typing versus contract-first codegen. At every level, live-fixing a broken snippet under strict mode is the most common practical format.

Is TypeScript still worth learning deeply in 2026, or is AI-assisted coding making it less relevant?

It has become more valuable, not less. AI coding assistants generate plausible-looking code at high volume, and the type checker is the cheapest reviewer that never gets tired: teams report that strongly-typed codebases catch a large share of AI-generated mistakes at compile time that would otherwise reach review or production. The ecosystem's direction confirms the bet: Node can run TypeScript files natively now, Microsoft is rewriting the compiler in Go for roughly 10x speed, and new frameworks assume TypeScript by default. In the Indian market specifically, TypeScript is the shared language across the highest-paying frontend, backend, and full-stack roles, so depth in it compounds across every stack you touch. The skill that differentiates candidates is no longer writing annotations, it is designing types that make wrong code fail to compile.

TypeScript vs plain JavaScript for job prospects in India: does the distinction still matter?

For hiring purposes, TypeScript has effectively absorbed JavaScript at product companies: job posts for React, Node.js, Angular, and full-stack roles list TypeScript either as required or strongly preferred, and interviews increasingly happen IN TypeScript regardless of what the post said. Pure-JavaScript roles still exist in older codebases and smaller agencies, but they cluster at the lower end of the salary range. The efficient strategy is to treat them as one skill with two layers: JavaScript gives you the runtime model (closures, prototypes, the event loop, promises), TypeScript gives you the static layer on top, and interviewers probe both, often in the same question. A candidate who knows TypeScript syntax but cannot explain what the emitted JavaScript does gets filtered out exactly as fast as one who knows JavaScript but fights the type checker.

Do I need to master advanced type-level programming (conditional types, infer, mapped types) to clear interviews?

It depends on the role's altitude. For application-developer roles up to about four years of experience, you need to READ advanced types comfortably (every modern codebase imports libraries built on them) and write the standard patterns: a mapped type, a simple conditional, a discriminated union with exhaustiveness. You will rarely be asked to build a recursive type-level parser. For library, platform, design-system, and senior product roles, type-level fluency is genuinely tested: rebuilding utility types, explaining distribution over unions, and designing typed event emitters or builders show up in real loops at strong product companies. A practical benchmark: if you can implement Partial, Pick, ReturnType, and a DeepReadonly from memory and explain WHY each works, you clear the bar for the large majority of Indian TypeScript interviews.

Introduction

TypeScript in 2026 is no longer a nice-to-have on a resume, it is the default language of the JavaScript ecosystem. New React, Node.js, and Angular codebases start in TypeScript by default, recent Node versions can execute .ts files directly by stripping types, and Microsoft is rewriting the compiler itself in Go (the tsgo native port, shipping as TypeScript 7) to make type checking roughly ten times faster on large repos. The language adds zero runtime behaviour: every annotation is erased at build time, which means the entire value of TypeScript lives in what the compiler can prove before your code ships. Interviews test exactly that: can you make the compiler prove the things that matter?

Indian product companies take TypeScript screening seriously. Razorpay, CRED, Zerodha, Flipkart, Swiggy, and Postman all run TypeScript-heavy stacks, and their interviewers go well past syntax: expect questions on structural typing and assignability, narrowing and control-flow analysis, discriminated unions, generics with constraints, conditional and mapped types, the strict flag family in tsconfig, module resolution in the ESM era, and how you validate untyped data at runtime boundaries with tools like Zod. Service companies like TCS and Infosys increasingly test TypeScript for Angular and Node roles too, though usually at a shallower depth focused on classes, interfaces, and enums.

This guide contains 60 TypeScript interview questions arranged from basic through advanced, matching how real interview loops escalate. The basic section locks down the type system fundamentals every candidate must answer without hesitation. The intermediate section covers the utility types, generics patterns, tsconfig flags, and tooling questions that decide mid-level offers. The advanced section goes where senior loops go: type-level programming, variance, compiler performance, declaration emit for libraries, and architecture decisions like migrating a large JavaScript codebase. Most technical answers include a runnable code example, and the FAQ at the end covers salaries, preparation time, and how TypeScript stacks up against plain JavaScript in the Indian market.

Ready to practice TypeScript interviews?

Don't just read, practice these TypeScript questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview