Ch. 02

TypeScript

Types that make interviewers nod: generics, narrowing, utility types, conditional and mapped types.

56 interview questions28 quiz questions3 notes
your progress0%

Notes in this chapter

Filter all notes →
read ✓TypeScript · easy

type vs interface: When It Actually Matters

Declaration merging, extends vs intersections, what only type aliases can express, and the few differences that change behavior, with a clear rule of thumb.

~5 min readread →
read ✓TypeScript · hard

Build TypeScript's Utility Types From Scratch

Reimplement Partial, Pick, Omit, Exclude, ReturnType, Awaited and friends with mapped and conditional types, plus modifiers, key remapping and template literals.

~6 min readread →

Top 56 TypeScript interview questions most asked first

  1. 1.What is TypeScript, and why would you use it instead of plain JavaScript?easy

    TypeScript is JavaScript with a static type system on top. You write JavaScript plus type annotations, the compiler checks them, and then the types are erased: the output is plain JavaScript, so there is no runtime cost and runtime behavior doesn't change.

    Why teams adopt it:

    • Bugs caught before running: typos in property names, wrong argument types, forgetting that a value can be null or undefined.
    • Tooling: autocomplete, go-to-definition and safe rename refactors all come from the type information.
    • Types as documentation: function signatures describe contracts between modules and teams, which matters more as a codebase grows.
    • Refactoring confidence: change a type and the compiler lists every place that needs updating.

    The trade-offs are a build or type-check step, a learning curve, and the fact that types can lie: any, careless assertions and unvalidated API data all bypass the checker, and nothing is enforced at runtime.

    What interviewers listen for
    • Typed layer over JavaScript, compiles to plain JavaScript
    • Types checked at compile time, erased at runtime
    • Catches bugs early and powers editor tooling
    • Makes refactoring large codebases safer
    • No runtime guarantees: external data still needs validation

    Likely follow-up: Does TypeScript make your code run faster? · What are the downsides of adopting TypeScript?

  2. 2.What is the difference between type and interface? When would you use each?easy

    For object shapes they're nearly interchangeable: both can be generic, extended, and implemented by a class. The real differences:

    • Only interfaces support declaration merging. Two interface User declarations merge into one, which is how you augment Window or a library's types. A duplicate type is an error.
    • Interfaces compose with extends, which reports conflicting properties. Type aliases compose with &, where a conflict silently becomes never.
    • Only type can name unions, tuples, primitives, and mapped, conditional or template literal types.
    • An object type alias gets an implicit index signature, so it's assignable to Record<string, T>; an interface isn't.

    My default is interface for object shapes, especially ones others extend or augment, and type for everything else. Consistency within the team matters more than the choice.

    interface User { id: number }
    interface User { name: string } // merged: { id; name }
    
    type Id = string | number; // only a type alias can name a union
    
    type Base = { id: string };
    type Broken = Base & { id: number }; // no error, but id is never
    interface Derived extends Base { id: number } // Error: incorrectly extends
    What interviewers listen for
    • Both describe object shapes; mostly interchangeable
    • Only interfaces merge; a duplicate type is an error
    • extends reports conflicts; & silently yields never
    • Unions, tuples, mapped and conditional types need type
    • Type aliases get implicit index signatures, interfaces don't

    Likely follow-up: How would you add a property to the global Window type?

  3. 3.Explain the difference between any, unknown and never.easy
    • any switches type checking off. You can assign it anywhere and call anything on it, and it spreads silently through every expression it touches. It's an escape hatch, mostly for migrations.
    • unknown is the type-safe "could be anything". Every value is assignable to it, but you can't use it (access properties, call it, assign it to a typed variable) until you narrow it with typeof, instanceof, a type guard or runtime validation. It's the right type for JSON.parse output, catch variables and untrusted input.
    • never is the empty type: no value has it. It's the return type of functions that always throw or loop forever, the type left over after exhaustive narrowing, and it disappears from unions (string | never is string).

    So unknown is the top type, never is the bottom type, and any is an opt-out of the type system. Prefer unknown whenever you're tempted to write any.

    let a: any = 'hi';
    a.foo.bar(); // compiles, crashes at runtime
    
    let u: unknown = 'hi';
    u.toUpperCase(); // Error: 'u' is of type 'unknown'
    if (typeof u === 'string') u.toUpperCase(); // OK after narrowing
    
    function fail(msg: string): never {
      throw new Error(msg);
    }
    What interviewers listen for
    • any disables checking and spreads silently
    • unknown accepts anything but must be narrowed before use
    • never has no values: throwing functions, impossible branches
    • never vanishes from unions
    • Prefer unknown over any for untrusted data

    Likely follow-up: Where does never show up without you writing it?

  4. 4.What are generics, and why use them instead of any?easy

    A generic is a type parameter: a placeholder for a type that gets filled in each time a function, class or type is used, usually inferred from the arguments. It lets you write reusable code while keeping the relationship between inputs and outputs.

    With any, a first(arr) helper returns any and every use of the result is unchecked. With first<T>(arr: T[]): T | undefined, calling it on a string[] returns string | undefined, so the type flows through.

    You use generics constantly: Array<T>, Promise<T>, Map<K, V>, React's useState. When inference can't work out the type, you pass it explicitly, like useState<User | null>(null).

    A good smell test: a type parameter should appear at least twice, linking a parameter to the return type or to another parameter. If it's used only once, a plain type annotation is usually enough.

    What interviewers listen for
    • Type parameter filled in per use, usually inferred
    • Preserves the link between input and output types
    • any discards type information; generics keep it
    • Pass type arguments explicitly when inference falls short
    • A type parameter used only once is usually unnecessary

    Likely follow-up: How do you restrict which types a generic accepts?

  5. 5.What are union and intersection types, and how do they differ?easy

    A union A | B means the value is one of the member types. Until you narrow it, you can only use what all members have in common: on string | number you can call toString() but not toUpperCase().

    An intersection A & B means the value is both at once, so it has every property of A and every property of B. It's mostly used to combine object types, like User & { permissions: string[] }.

    The naming feels backwards at first: a union accepts more values but lets you do less with them, while an intersection accepts fewer values but guarantees more properties.

    Intersecting incompatible types produces never: string & number is never, and if two object types disagree on a property, that property becomes never (or the whole type does, for conflicting literal discriminants).

    What interviewers listen for
    • Union A | B: the value is one of the members
    • Only shared members are usable until you narrow
    • Intersection A & B: the value satisfies both types
    • Intersected object types have all properties from each
    • Incompatible intersections like string & number become never
  6. 6.What is type narrowing, and which narrowing techniques does TypeScript understand?easy

    Narrowing is TypeScript's control-flow analysis refining a broad type, usually a union, to a more specific one inside a branch, based on the checks you write. It understands:

    • typeof x === 'string' for primitives (remember typeof null is 'object').
    • x instanceof Date for class instances.
    • 'swim' in animal to check that a property exists.
    • Equality checks: x === null, x !== undefined, or comparing a literal discriminant like shape.kind === 'circle'.
    • Truthiness: if (x) removes null and undefined, but also 0 and ''.
    • Array.isArray(x), and your own type predicates (x is Fish) and assertion functions.

    Narrowing follows early returns and throws too: after if (!user) return;, user is non-null for the rest of the function. In the last branch of an exhaustive check, what's left is never.

    function format(value: string | number | Date | null) {
      if (value === null) return 'n/a';
      if (typeof value === 'number') return value.toFixed(2);
      if (value instanceof Date) return value.toISOString();
      return value.toUpperCase(); // only string is left
    }
    What interviewers listen for
    • Control-flow analysis refines a union inside branches
    • typeof, instanceof, in, equality and truthiness checks
    • Discriminant property checks narrow discriminated unions
    • Early returns and throws narrow the rest of the scope
    • Custom type predicates for checks TS can't infer

    Likely follow-up: Why is if (value) risky when value can be a number or a string?

  7. 7.Which built-in utility types do you use most often, and what do they do?easy

    The everyday ones:

    • Partial<T> makes every property optional (update payloads, patch functions); Required<T> removes the ? from every property.
    • Readonly<T> marks every property readonly.
    • Pick<T, K> keeps only the keys K; Omit<T, K> drops them.
    • Record<K, V> builds an object type with keys K and values V, like Record<Status, string>.
    • Exclude<U, X> and Extract<U, X> filter the members of a union; NonNullable<T> removes null and undefined.
    • ReturnType<F>, Parameters<F> and Awaited<T> derive types from functions and promises.

    They compose well, for example Partial<Omit<User, 'id'>> for an update form. They're all just mapped and conditional types under the hood, so they're easy to reimplement. One gotcha: Partial and Readonly are shallow, they only affect top-level properties.

    interface User { id: number; name: string; email?: string }
    
    type UserPatch = Partial<Omit<User, 'id'>>; // { name?: string; email?: string }
    type UserPreview = Pick<User, 'id' | 'name'>;
    type CompleteUser = Required<User>;          // email is now required
    type UsersById = Record<number, User>;
    type FrozenUser = Readonly<User>;            // shallow
    What interviewers listen for
    • Partial, Required, Readonly change property modifiers
    • Pick and Omit select or drop keys
    • Record<K, V> builds a keyed object type
    • Exclude, Extract, NonNullable filter union members
    • Built from mapped and conditional types; modifiers are shallow

    Likely follow-up: How would you implement Partial yourself? · How would you write a DeepPartial?

  8. 8.Would you use an enum or a union of string literals? And what is a const enum?mid

    A literal union like type Status = 'idle' | 'loading' is pure type: it's erased, costs nothing at runtime, accepts plain strings from JSON, and narrows well.

    An enum is one of the few TypeScript features that emits runtime code: a real object you can reference or iterate. The quirks:

    • String enums are nominal: you can't pass 'idle' where Status.Idle is expected, which is awkward with API data.
    • Numeric enums get a reverse mapping, and still accept any number-typed value.
    • Enums aren't erasable syntax, so they're rejected under erasableSyntaxOnly, the mode meant for runtimes that simply strip types.

    A const enum is inlined at each use site and emits no object, but tools that compile one file at a time (Babel, esbuild, SWC) can't inline one imported from another file, so many teams avoid it.

    My default is a literal union, or an as const object plus a derived union when I also need the values at runtime.

    enum Status { Idle = 'idle', Loading = 'loading' }
    const s1: Status = Status.Idle;
    const s2: Status = 'idle'; // Error: string enums are nominal
    
    type StatusU = 'idle' | 'loading';
    const s3: StatusU = 'idle'; // fine, and erased at runtime
    
    const STATUS = { Idle: 'idle', Loading: 'loading' } as const;
    type StatusC = (typeof STATUS)[keyof typeof STATUS]; // 'idle' | 'loading'
    What interviewers listen for
    • Literal unions are erased: zero runtime cost
    • Enums emit a runtime object; string enums are nominal
    • const enum inlines values but trips up per-file transpilers
    • Enums are rejected under erasableSyntaxOnly
    • as const object plus derived union as an alternative
  9. 9.TypeScript is described as structurally typed. What does that mean in practice?easy

    Type compatibility is decided by shape, not by name or declaration. If a value has at least the members a type requires, with compatible types, it's assignable, even if it was never declared as that type. That matches how JavaScript objects are actually used ("duck typing"), and it contrasts with nominal languages like Java or C#, where a class must explicitly declare that it implements an interface.

    Consequences:

    • Two interfaces with the same shape are interchangeable.
    • A class instance can be passed where an interface is expected without implements.
    • Extra properties are fine when you pass an existing variable; only fresh object literals get excess property checks.

    The downside is that you can't tell a UserId from an OrderId if both are just string. That's what branded types are for. (Classes with private or #private members are the exception: they only match their own declaration.)

    interface Point { x: number; y: number }
    class Vec { constructor(public x: number, public y: number) {} }
    function len(p: Point) { return Math.hypot(p.x, p.y); }
    
    len(new Vec(3, 4));        // OK: same shape, no implements needed
    const p3 = { x: 1, y: 2, z: 3 };
    len(p3);                   // OK: extra property via a variable
    len({ x: 1, y: 2, z: 3 }); // Error: excess property check on a literal
    What interviewers listen for
    • Assignability depends on shape, not declared names
    • Extra members are fine; missing members are errors
    • Classes match interfaces without implements
    • Fresh object literals still get excess property checks
    • Same-shaped IDs are interchangeable; brands fix that
  10. 10.What do type assertions (as) and the non-null assertion (!) do, and why are they risky?easy

    A type assertion, value as Type, tells the compiler "trust me, it's this type". It performs no runtime check or conversion; it's simply erased. TypeScript only allows it when the two types overlap enough (one is assignable to the other); otherwise you need a double assertion through unknown, which is a red flag in review.

    The non-null assertion x! removes null and undefined from the type of x.

    Both are risky because if you're wrong, nothing complains at compile time and the failure appears at runtime, often far from the cause. They also keep silencing the compiler after the surrounding code changes.

    Reasonable uses: DOM lookups where you control the markup, like querySelector('#c') as HTMLCanvasElement, and test code. Otherwise prefer narrowing, type guards, satisfies, or runtime validation. (as const is different: it's a const assertion that makes a literal more specific, not less safe.)

    const root = document.getElementById('app')!; // HTMLElement, trusted to exist
    const canvas = document.querySelector('#c') as HTMLCanvasElement;
    
    const n = 'hello' as number;                 // Error: types don't overlap
    const forced = 'hello' as unknown as number; // compiles, but it's a lie
    
    type User = { name: string };
    const u = {} as User; // compiles; u.name is undefined at runtime
    What interviewers listen for
    • Assertions are compile-time only: no runtime check
    • ! strips null and undefined from a type
    • Only allowed when types overlap; otherwise via unknown
    • Wrong assertions become runtime errors far away
    • Prefer narrowing, type guards or validation

    Likely follow-up: How is as const different from a normal type assertion?

  11. 11.What is a user-defined type guard, and how is an assertion function different?mid

    When a check lives in a helper function, TypeScript doesn't look inside it: a helper that returns boolean doesn't narrow anything at the call site.

    A type predicate fixes that. Declaring the return type as pet is Fish tells the compiler "if this returns true, treat the argument as Fish", and in the else branch it removes Fish from the union.

    An assertion function is declared asserts value is T (or just asserts condition). It either returns normally or throws, so there's no if: everything after the call is narrowed. It's handy for invariants like assertDefined(user) or validating input at a boundary. One quirk: the function must be referenced through an explicitly typed name, so an arrow function in a const needs a type annotation.

    The big caveat: the compiler trusts your implementation. A predicate with a wrong body is just a hidden as, so keep guards small and tested, or derive them from a schema.

    type Fish = { swim(): void };
    type Bird = { fly(): void };
    
    function isFish(pet: Fish | Bird): pet is Fish {
      return 'swim' in pet;
    }
    function assertDefined<T>(v: T): asserts v is NonNullable<T> {
      if (v == null) throw new Error('Expected a value');
    }
    
    declare const pet: Fish | Bird;
    if (isFish(pet)) pet.swim(); else pet.fly();
    What interviewers listen for
    • Boolean-returning helpers don't narrow at the call site
    • x is T predicate narrows both branches of an if
    • asserts x is T narrows everything after the call
    • Assertion functions throw instead of returning false
    • The compiler trusts the body; a wrong guard lies

    Likely follow-up: Can TypeScript ever infer a type predicate for you?

  12. 12.What is a discriminated union, and how do you make a switch over it exhaustive?mid

    A discriminated (tagged) union is a union of object types that all share one property, the discriminant, with a different literal type in each member, like status: 'loading' | 'success' | 'error'. Checking that property narrows the whole object, so in the 'success' branch TypeScript knows data exists.

    It's the idiomatic way to model state because it makes impossible states unrepresentable. Compare it with { loading: boolean; data?: T; error?: string }, which allows loading: true together with an error.

    For exhaustiveness, assign the value to a never-typed variable in the default branch. If every case is handled, the value really is never there and it compiles. When someone adds a new member to the union, that line fails to compile and points straight at the switch that needs updating. An assertNever(x: never) helper that throws does the same and also guards at runtime.

    type State =
      | { status: 'loading' }
      | { status: 'success'; data: string[] }
      | { status: 'error'; error: Error };
    function render(s: State): string {
      switch (s.status) {
        case 'loading': return 'Loading...';
        case 'success': return s.data.join(', '); // narrowed: data exists
        case 'error': return s.error.message;
        default: { const done: never = s; return done; } // breaks if a case is missing
      }
    }
    What interviewers listen for
    • Shared literal property distinguishes each union member
    • Checking the discriminant narrows the whole object
    • Models state so impossible combinations can't exist
    • Assign to never in default for exhaustiveness
    • Adding a member then fails compilation at every switch

    Likely follow-up: How would you type the actions of a useReducer with this pattern?

  13. 13.How do you type a React component's props, children, event handlers and hooks?mid
    • Props: a type or interface, destructured in the signature: function Button({ label, onClick }: ButtonProps). React.FC no longer adds children implicitly, so either way declare children?: React.ReactNode when you accept children.
    • Wrapping native elements: intersect with ComponentProps<'button'> (or ComponentPropsWithoutRef) so disabled, type and aria-* all work.
    • Events: ChangeEvent<HTMLInputElement>, MouseEvent<HTMLButtonElement>, FormEvent<HTMLFormElement> from React (not the DOM globals), or type the prop as ChangeEventHandler<HTMLInputElement>. Inline handlers are inferred from context.
    • Hooks: useState infers from the initial value; pass a type when that value is null or [], like useState<User | null>(null). DOM refs are useRef<HTMLInputElement>(null), and ref.current must be null-checked. A discriminated union of actions types useReducer well.
    • Generic components: function List<T>(props: ListProps<T>) lets renderItem infer the item type.
    import { useRef, useState, type ChangeEvent, type ComponentProps } from 'react';
    
    type ButtonProps = ComponentProps<'button'> & { variant?: 'primary' | 'ghost' };
    
    function useSearch() {
      const [query, setQuery] = useState('');               // string, inferred
      const [results, setResults] = useState<string[]>([]); // [] needs a type
      const [error, setError] = useState<Error | null>(null);
      const inputRef = useRef<HTMLInputElement>(null);      // current can be null
      const onChange = (e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value);
      return { query, results, error, inputRef, onChange };
    }
    What interviewers listen for
    • Props as a type or interface, destructured in the signature
    • Declare children: React.ReactNode explicitly
    • ComponentProps<'button'> to extend native element props
    • React event types like ChangeEvent<HTMLInputElement>
    • Type useState when the initial value is null or []

    Likely follow-up: How would you type a generic Select component whose onChange returns the selected item?

  14. 14.How do generic constraints work? Explain K extends keyof T with an example.mid

    An unconstrained type parameter can be anything, so inside the function you can't assume it has any members. extends adds a constraint: with T extends { length: number } you may use .length, and callers still get their exact type back (a number[] stays number[]), not just { length: number }. In a constraint, extends means "is assignable to", not class inheritance.

    The classic example is getProp<T, K extends keyof T>(obj: T, key: K): T[K]:

    • K must be one of T's keys, so a typo like 'emial' is a compile error.
    • The return type T[K] is an indexed access type, so getProp(user, 'name') returns exactly string.

    Constraints can refer to other type parameters, as K refers to T here. Don't confuse a constraint with a default: <T extends string> restricts what's allowed, <T = string> only says what to use when nothing is inferred or passed.

    function longest<T extends { length: number }>(a: T, b: T): T {
      return a.length >= b.length ? a : b;
    }
    longest([1, 2], [3]);  // returns number[], not { length: number }
    longest(1, 2);         // Error: number has no 'length'
    
    function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
      return obj[key];
    }
    const user = { id: 1, name: 'Ada' };
    const n = getProp(user, 'name'); // string
    getProp(user, 'email');          // Error: not a key of user
    What interviewers listen for
    • extends restricts what a type parameter accepts
    • Constraint enables member access inside the function
    • Callers keep their exact type, not the constraint
    • K extends keyof T limits keys; T[K] gives the value type
    • A constraint is not a default (<T = X>)
  15. 15.What do the keyof and typeof type operators do, and what are indexed access types?mid
    • keyof T produces a union of T's property names as literal types: keyof { id: number; name: string } is 'id' | 'name'. With a string index signature you get string | number, because numeric keys are allowed too.
    • typeof x in a type position gives you the type of a value, so you can derive types from existing code instead of duplicating them: type Config = typeof defaultConfig. It's unrelated to the runtime typeof operator, which returns a string; TypeScript tells them apart by position.
    • An indexed access type T['name'] looks up the type of a property. Indexing with a union gives a union, T[keyof T] gives all value types, and T[number] gives the element type of an array or tuple.

    They're often combined: keyof typeof obj for an object's keys, and (typeof ROLES)[number] to turn an as const array into a union, so one runtime list is the single source of truth.

    What interviewers listen for
    • keyof T is the union of property names
    • typeof value lifts a value's type into type space
    • T[K] looks up property types; T[number] gets elements
    • keyof typeof obj derives keys from a runtime object
    • Derive types from values instead of duplicating them
  16. 16.What does the strict compiler option enable, and why do strictNullChecks and noImplicitAny matter so much?easy

    strict is an umbrella flag that turns on a whole family of checks. The two that matter most:

    • strictNullChecks: null and undefined become their own types instead of being allowed everywhere. Without it, let s: string = null compiles and reading a property of a possibly missing value isn't flagged, which is the classic runtime crash. With it, you have to handle absence with narrowing, ?. or ??.
    • noImplicitAny: an error whenever TypeScript can't infer a type and would silently fall back to any, such as an unannotated parameter.

    The family also includes strictFunctionTypes, strictPropertyInitialization (class fields must be initialized), strictBindCallApply, noImplicitThis, useUnknownInCatchVariables and alwaysStrict.

    Every new project should use strict, and newer compiler versions enable it by default. Checks added to the family later are switched on automatically, so upgrades can surface new errors. When migrating an old codebase, turn the flags on one at a time.

    What interviewers listen for
    • strict is an umbrella for several stricter checks
    • strictNullChecks: null and undefined must be handled
    • noImplicitAny: no silent fallback to any
    • Also strict function types and property initialization
    • Enable for new projects; flag by flag when migrating
  17. 17.What is the difference between an optional property name?: string and name: string | undefined?easy

    name?: string means the key may be missing entirely; reading it gives string | undefined. name: string | undefined means the key must be present, but its value may be undefined: {} isn't assignable, { name: undefined } is.

    Parameters work the same way. f(x?: number) can be called as f(), while f(x: number | undefined) forces callers to write f(undefined) explicitly, which is sometimes exactly what you want.

    By default an optional property also accepts an explicit undefined. With exactOptionalPropertyTypes enabled, name?: string means "absent or a string": writing { name: undefined } becomes an error unless you declare name?: string | undefined. The distinction matters at runtime because 'name' in obj, Object.keys and object spread all treat a missing key differently from a key holding undefined.

    What interviewers listen for
    • ? means the key may be absent
    • | undefined means present, but possibly undefined
    • Optional parameters can be omitted at the call site
    • exactOptionalPropertyTypes rejects explicit undefined for ?
    • Missing key and undefined value differ at runtime
  18. 18.How does type inference work in TypeScript, and when should you still write explicit annotations?easy

    TypeScript infers types from initializers (let count = 0 is number), from return statements, and from context: a callback passed to items.map or an onClick prop gets its parameter types from where it's used. A const holding a literal keeps the literal type (const mode = 'dark' is 'dark'), while let widens it to string.

    Where I still annotate:

    • Function parameters, which aren't inferred from how they're used (only contextually typed callbacks get them for free).
    • Return types of exported functions: it documents intent and catches accidentally returning the wrong shape inside the function rather than at every caller.
    • Empty or null initial values: const ids: string[] = [], useState<User | null>(null).
    • Object literals meant to match a type, via an annotation or satisfies, to get excess property checks.

    Everywhere else I let inference work. Redundant annotations are noise, and can even lose information, like const x: string = 'a' dropping the literal type.

    What interviewers listen for
    • Inferred from initializers, returns and context
    • const keeps literal types; let widens them
    • Annotate parameters and exported return types
    • Annotate empty arrays and null initial values
    • Avoid redundant annotations; they can widen types
  19. 19.What does void mean as a return type, and how is it different from undefined and never?easy

    void says "this function's return value isn't meant to be used". A function with no return, or a bare return;, is inferred as void.

    never is different: the function never returns normally at all, because it always throws or loops forever. undefined as a return type is stricter than void: callers can rely on the value actually being undefined.

    The subtle part is function types. A type like () => void accepts functions that do return something; the result is simply treated as unusable. That's deliberate, so nums.forEach((n) => list.push(n)) type-checks even though push returns a number. But when you annotate a function declaration itself with : void, returning a value is an error.

    So: void for callbacks and side-effecting functions, never for functions that can't return, and undefined only when callers should depend on getting undefined.

    function log(msg: string): void { console.log(msg); }
    function crash(): never { throw new Error('boom'); }
    
    type Callback = () => void;
    const cb: Callback = () => 42; // OK: the return value is ignored
    const result = cb();           // result is void, not number
    
    const list: number[] = [];
    [1, 2].forEach((n) => list.push(n)); // push returns number; still fine
    
    function bad(): void { return 42; }  // Error: not assignable to void
    What interviewers listen for
    • void: return value should be ignored
    • never: function never returns normally
    • undefined return type is a stronger promise
    • () => void function types accept value-returning functions
    • A declaration annotated : void can't return a value
  20. 20.What are mapped types? Walk me through how you would implement Partial or Readonly yourself.hard

    A mapped type builds an object type by iterating over a union of keys: { [K in keyof T]: T[K] } copies T property by property. From there you transform each property:

    • Modifiers: adding ? or readonly sets them, -? and -readonly remove them. So Partial<T> is { [K in keyof T]?: T[K] }, Required<T> uses -?, and Readonly<T> prefixes readonly.
    • Value types: { [K in keyof T]: boolean } gives a "touched" flag per form field.
    • Key remapping with as: rename keys, for example with template literal types to generate getName-style getters, or map a key to never to drop it, which is how you filter properties by their value type.

    Mapping over keyof T of a generic T is called homomorphic: it preserves the original readonly and ? modifiers, and arrays and tuples stay arrays and tuples. Mapped types are shallow unless you recurse.

    type MyPartial<T> = { [K in keyof T]?: T[K] };
    type Mutable<T> = { -readonly [K in keyof T]: T[K] };
    type Getters<T> = {
      [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
    };
    type OnlyStrings<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] };
    
    type User = { id: number; name: string };
    type G = Getters<User>;     // { getId: () => number; getName: () => string }
    type S = OnlyStrings<User>; // { name: string }
    What interviewers listen for
    • [K in keyof T] iterates keys to build a new type
    • ?/readonly add modifiers; -?/-readonly remove them
    • as remaps keys; mapping to never drops a key
    • Homomorphic mapped types preserve modifiers and tuples
    • Shallow unless written recursively

    Likely follow-up: How would you write a type that keeps only the function-valued properties of an object?

  21. 21.What are conditional types, and what does the infer keyword do?hard

    A conditional type T extends U ? X : Y is a type-level ternary: if T is assignable to U the result is X, otherwise Y. While T is still an unresolved type parameter the conditional stays deferred, and it's evaluated once T is known. NonNullable<T> is essentially T extends null | undefined ? never : T.

    infer declares a type variable inside the extends clause that TypeScript fills in by pattern matching:

    • T extends Promise<infer U> ? U : T unwraps a promise.
    • T extends (...args: any[]) => infer R ? R : never is how ReturnType works.
    • T extends readonly (infer E)[] ? E : never extracts an array's element type.

    infer is only allowed in the extends clause of a conditional type, and you can constrain it, as in infer K extends string. Two things to mention: when T is a naked type parameter, conditionals distribute over unions, and a generic function whose return type is a conditional usually needs an assertion in its body, because TypeScript won't narrow T from a runtime check.

    type IsString<T> = T extends string ? true : false;
    type A = IsString<'hi'>; // true
    
    type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
    type B = UnwrapPromise<Promise<number>>; // number
    type C = UnwrapPromise<string>;          // string
    
    type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
    type D = MyReturnType<() => Date>;       // Date
    
    type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
    type E = ElementOf<string[]>;            // string
    What interviewers listen for
    • T extends U ? X : Y is a type-level ternary
    • Deferred while T is an unresolved type parameter
    • infer captures a type by pattern matching
    • ReturnType and promise unwrapping are built this way
    • Distributes over unions when T is naked

    Likely follow-up: How would you get the type of the first element of a tuple?

  22. 22.What does as const do, and when is it useful?mid

    as const is a const assertion: it asks TypeScript to infer the narrowest type for a literal expression.

    • Literals keep their literal types instead of widening ('GET' rather than string).
    • Object properties become readonly, recursively for nested literals.
    • Array literals become readonly tuples: ['a', 'b'] as const is readonly ['a', 'b'], not string[].

    Typical uses: defining a list of values once and deriving a union from it with (typeof ROLES)[number]; lookup tables and config objects whose literal values should be preserved; returning a tuple from a custom hook (return [value, toggle] as const) so destructuring keeps each position's type; and passing object properties to functions that expect a literal union.

    It's compile-time only: it doesn't freeze anything at runtime (that's Object.freeze). It can only be applied to literal expressions, not to an arbitrary variable, and it combines nicely with satisfies to validate the shape while keeping the literals.

    What interviewers listen for
    • Infers the narrowest literal types, no widening
    • Properties become readonly, recursively
    • Arrays become readonly tuples
    • Derive unions from values: (typeof ROLES)[number]
    • Compile-time only; doesn't freeze at runtime
  23. 23.What does the satisfies operator do, and how is it different from a type annotation or as?mid

    expr satisfies Type checks that the expression is assignable to Type without changing its inferred type.

    • A type annotation (const x: Type = ...) also checks, but the variable's type becomes Type, so you lose the specifics: with Record<string, Route> you no longer know which keys exist, and with a union-typed property you have to narrow it again on every use.
    • as Type barely checks at all (only that the types overlap) and also replaces the type, so it can hide mistakes.
    • satisfies gives you the full check, including missing keys and excess property errors, while keeping the precise inferred type for autocomplete and narrowing.

    It shines for config objects, route tables, theme palettes and lookup maps. Combining it with a const assertion, { ... } as const satisfies Config, validates the shape and keeps readonly literal values.

    type Color = string | [number, number, number];
    type Palette = Record<'red' | 'green', Color>;
    
    const a: Palette = { red: [255, 0, 0], green: '#0f0' };
    a.green.toUpperCase(); // Error: could be a tuple
    
    const b = { red: [255, 0, 0], green: '#0f0' } satisfies Palette;
    b.green.toUpperCase();     // OK: still known to be a string
    b.red.map((c) => c / 255); // OK: still an array
    
    const c = { red: '#f00', blue: '#00f' } satisfies Palette; // Error: 'blue' not allowed
    What interviewers listen for
    • Checks assignability without changing the inferred type
    • Annotations widen the variable to the annotated type
    • as skips most checking and can hide mistakes
    • Still reports missing keys and excess properties
    • Great for configs; pairs well with as const
  24. 24.What are literal types, and what is type widening?easy

    A literal type is a single specific value used as a type: 'GET', 42, true. On its own that's rarely useful, but unions of literals model a fixed set of options: type Method = 'GET' | 'POST', type Dice = 1 | 2 | 3 | 4 | 5 | 6. (boolean itself is just true | false.)

    Widening is how inference decides between the literal and its base type. const x = 'GET' can never change, so its type is 'GET'. let x = 'GET' can be reassigned, so it widens to string. Object properties and array elements are mutable too, so const req = { method: 'GET' } infers method: string, even though req is a const.

    That's why passing req.method to a function that expects 'GET' | 'POST' fails, a very common surprise. The fixes: annotate the object with the right type, use as const, or use satisfies.

    What interviewers listen for
    • A literal type is one exact value
    • Unions of literals model fixed sets of options
    • const keeps the literal; let widens to the base type
    • Object properties widen because they're mutable
    • Fix with an annotation, as const or satisfies
  25. 25.What is a tuple type, and how is it different from an array type?mid

    An array type T[] has any length and one element type. A tuple has a known length (or a known structure) and a type per position: [string, number]. Tuples model positional results, like React's useState returning [state, setState] or Object.entries returning [string, T][].

    Tuple features:

    • Labels like [x: number, y: number], which only improve readability and editor hints.
    • Optional elements [string, number?] and rest elements [string, ...number[]].
    • readonly [number, number] to forbid mutation, and T['length'] is a literal type such as 2.

    Gotchas: a tuple is a plain array at runtime, and a mutable tuple still has push, which TypeScript won't track, so prefer readonly tuples. Also, an array literal like [1, 'a'] infers (string | number)[], not a tuple, unless there's a contextual tuple type, a return type annotation, or as const.

    What interviewers listen for
    • Fixed positions, each with its own type
    • Labeled, optional and rest elements are supported
    • Array literals infer arrays, not tuples, by default
    • as const or annotations produce tuples
    • Mutable tuples still allow push; prefer readonly

    Likely follow-up: What are variadic tuple types used for?

  26. 26.What are function overloads, and when would you use them instead of a union parameter or a generic?mid

    Overloads give one function several call signatures. You write the overload signatures without bodies, followed by a single implementation signature that is compatible with all of them. Callers only see the overloads, never the implementation signature, and TypeScript picks the first overload that matches from top to bottom, so order them from most specific to least.

    They're useful when the return type depends on the argument types in a way a union can't express: parse(input: string): Row but parse(input: string[]): Row[]. The DOM's createElement and addEventListener typings use this idea too.

    The costs: the implementation is only checked against its own broad signature, so it can return the wrong thing for a particular overload without an error. And a caller holding a string | number can't call it unless some overload accepts the union.

    So I reach for a union parameter first, then a generic, and use overloads only when the input-to-output mapping really needs them.

    What interviewers listen for
    • Multiple call signatures plus one implementation
    • Implementation signature is invisible to callers
    • First matching overload wins; order specific first
    • Implementation isn't checked per overload
    • Prefer unions or generics when they suffice
  27. 27.Explain public, private, protected and readonly in classes. How is TypeScript's private different from a #private field?easy
    • public is the default: accessible anywhere.
    • protected: accessible inside the class and its subclasses.
    • private: accessible only inside the declaring class.
    • readonly: can only be assigned in its declaration or in the constructor.

    Parameter properties like constructor(private readonly repo: Repo) declare and assign a field in one step.

    The key difference: TypeScript's private is compile-time only. After the types are erased it's an ordinary property, so it's reachable with bracket notation (obj['secret'] even type-checks) or a cast, and it shows up in JSON.stringify and Object.keys. A #secret field is a JavaScript feature enforced by the runtime: truly inaccessible from outside the class body, not enumerable, and invisible even to subclasses.

    Use # when privacy must really hold, for example in a library. TypeScript's modifiers are fine for documenting intent within an app, and protected has no # equivalent.

    What interviewers listen for
    • public default; protected includes subclasses; private class only
    • readonly assignable only at declaration or in constructor
    • TS private is erased: reachable at runtime
    • #private is enforced by the JavaScript runtime
    • Parameter properties declare and assign in one step
  28. 28.What is an abstract class, and when would you choose one over an interface?mid

    An abstract class can't be instantiated directly (new Shape() is an error). It's a base class that can hold real implementation, such as fields, a constructor and concrete methods, plus abstract members that every subclass must implement. An interface is purely a type contract: no implementation, and nothing left at runtime.

    I'd pick an abstract class when:

    • Subclasses should share code or state, like a template method where a concrete describe() calls an abstract area().
    • I need a runtime value, for instanceof checks or as a dependency-injection token (Angular uses abstract classes this way).

    I'd pick an interface when I only need a contract. A class can implements many interfaces but extends only one class, and plain objects can satisfy an interface without any class at all.

    They combine well: an interface for the public contract and an abstract class as an optional partial implementation.

    What interviewers listen for
    • Abstract classes can't be instantiated with new
    • Mix concrete implementation with abstract members
    • Interfaces are contracts only, erased at runtime
    • Abstract classes exist at runtime: instanceof, DI tokens
    • Single extends, but many implements
  29. 29.What is a .d.ts declaration file, and what is DefinitelyTyped?easy

    A .d.ts file contains only type information: signatures, interfaces and declare statements, with no implementation. It describes JavaScript that exists somewhere else, so TypeScript can type-check code that uses it. When you publish a library, declaration: true makes the compiler generate .d.ts files next to the compiled JavaScript so consumers get types.

    For JavaScript packages that don't ship their own types, the community maintains them in the DefinitelyTyped repository, published to npm under the @types scope: @types/lodash, @types/node, @types/react. When you import such a package, the compiler finds its @types package; which global type packages (like Node or test-runner globals) are loaded is controlled by the types compiler option.

    You write your own declarations for untyped modules, for globals injected by a bundler or a script tag, and for non-code imports like *.svg or *.css. skipLibCheck skips checking .d.ts files, which speeds up builds.

    What interviewers listen for
    • Types only: no runtime code
    • Describes JavaScript implemented elsewhere
    • Generated with declaration: true for libraries
    • DefinitelyTyped publishes community types as @types/*
    • Hand-write them for globals, untyped modules, asset imports
  30. 30.What does readonly do, and how does it differ from const and Object.freeze?easy

    const is JavaScript: the variable binding can't be reassigned, but the object it points to can still be mutated.

    readonly is a TypeScript property modifier: the property can't be reassigned after initialization. readonly string[] (or ReadonlyArray<string>) removes mutating methods like push and splice from the type, and Readonly<T> marks every property readonly.

    Caveats worth mentioning:

    • It's compile-time only: nothing stops mutation at runtime.
    • It's shallow: nested objects stay mutable unless they're readonly too.
    • An object with readonly properties is still assignable to a type with the same properties writable, so it's not a hard guarantee. Readonly arrays are stricter: readonly number[] isn't assignable to number[].

    Object.freeze is the runtime counterpart (also shallow), and TypeScript types its result as Readonly. I use readonly on props, state and array parameters I don't intend to mutate.

    What interviewers listen for
    • const protects the binding, not the object
    • readonly blocks property reassignment at compile time
    • readonly T[] removes mutating array methods
    • Shallow, and erased at runtime
    • Object.freeze enforces (shallowly) at runtime
  31. 31.What is the difference between Exclude and Omit? And what do Extract and NonNullable do?mid

    They work on different kinds of types:

    • Exclude<U, X> works on a union: it removes the members assignable to X. Exclude<'a' | 'b' | 'c', 'a'> is 'b' | 'c'.
    • Omit<T, K> works on an object type: it removes properties. Omit<User, 'password'> is User without password. It's built from the other one: Pick<T, Exclude<keyof T, K>>.
    • Extract<U, X> is the opposite of Exclude: it keeps the members assignable to X, which is handy for pulling one member out of a discriminated union.
    • NonNullable<T> removes null and undefined.

    Two Omit gotchas worth knowing. Its keys aren't checked against T, so a typo like Omit<User, 'pasword'> compiles silently, while Pick would reject it. And it isn't distributive: on a union of object types it keeps only the common keys, so you need T extends unknown ? Omit<T, K> : never to omit from each member.

    type Status = 'idle' | 'loading' | 'success' | 'error';
    type Busy = Exclude<Status, 'idle'>;               // 'loading' | 'success' | 'error'
    type Done = Extract<Status, 'success' | 'error'>;  // 'success' | 'error'
    type Name = NonNullable<string | null | undefined>; // string
    
    interface User { id: number; name: string; password: string }
    type PublicUser = Omit<User, 'password'>; // { id: number; name: string }
    type Typo = Omit<User, 'pasword'>;        // compiles! keys aren't checked
    type Picked = Pick<User, 'pasword'>;      // Error: Pick checks its keys
    
    type Shape = { kind: 'circle'; r: number } | { kind: 'square'; size: number };
    type Circle = Extract<Shape, { kind: 'circle' }>; // { kind: 'circle'; r: number }
    What interviewers listen for
    • Exclude filters union members; Omit removes object properties
    • Omit is Pick plus Exclude on keyof T
    • Extract keeps matching members, e.g. one union variant
    • Omit doesn't check keys; Pick does
    • Omit on a union collapses it to common keys
  32. 32.How do ReturnType, Parameters and Awaited help you derive types from existing code?mid

    They derive types from functions and promises instead of duplicating definitions, so the types stay in sync when the function changes.

    • ReturnType<typeof fn> gives the return type. You need typeof because these utilities take a type, and fn is a value.
    • Parameters<typeof fn> gives the parameter list as a tuple, so Parameters<typeof fn>[0] is the first argument's type.
    • Awaited<T> unwraps promises recursively: Awaited<Promise<Promise<number>>> is number. Awaited<ReturnType<typeof fetchUser>> is the resolved value of an async function.
    • For classes there are ConstructorParameters and InstanceType.

    Real uses: typing the result of a library function whose types aren't exported, Redux's RootState = ReturnType<typeof store.getState>, and wrappers like (...args: Parameters<typeof fn>) => ....

    One caveat: on an overloaded function, ReturnType and Parameters only see the last overload signature.

    What interviewers listen for
    • Derive types from values to avoid duplication
    • Need typeof fn: utilities take types, not values
    • Parameters returns a tuple; index it for one argument
    • Awaited unwraps nested promises recursively
    • Overloaded functions: only the last signature is used
  33. 33.Do TypeScript types exist at runtime? Why can't you write x instanceof User when User is an interface?easy

    No. Types are erased during compilation: interfaces, type aliases, annotations, generics and assertions all disappear, and what runs is plain JavaScript.

    So an interface isn't a value. x instanceof User doesn't compile ("only refers to a type, but is being used as a value here"), and there's no way to list an interface's keys at runtime. Generics are erased too, so inside a generic function you can't write new T() or check what T is.

    What does exist at runtime are the features that emit code: classes (so instanceof works with them), enums, and namespaces that contain values.

    The practical consequence: a function typed (user: User) can still receive garbage from an API, localStorage or JSON.parse. Checks have to be written in real JavaScript: typeof, in, Array.isArray, custom type guards, or a schema library like zod that validates at runtime and gives you the static type from the same schema.

    What interviewers listen for
    • Types are erased; output is plain JavaScript
    • Interfaces aren't values: no instanceof, no runtime keys
    • Generic type parameters don't exist at runtime
    • Classes and enums do emit runtime code
    • Validate external data with real runtime checks

    Likely follow-up: How would you check at runtime that an unknown value is a User?

  34. 34.How do you type functions in TypeScript, including optional, default and rest parameters and function types?easy
    • Parameters are annotated; the return type is usually inferred but worth writing on exported functions: function add(a: number, b: number): number.
    • Optional parameters b?: number must come after required ones. Default parameters b = 10 get their type from the default and are optional for callers.
    • Rest parameters: ...nums: number[], or a tuple type for a fixed structure.
    • Function type expressions describe callbacks: type Handler = (event: MouseEvent) => void. When a function also has properties, use a call signature in an object type: { (x: number): string; displayName: string }.
    • A function assigned to a typed variable or passed as a callback gets contextually typed parameters, so const h: Handler = (e) => ... needs no annotation on e.
    • You can declare a fake first this parameter, function (this: HTMLElement), which is erased from the output.

    Also, a function with fewer parameters is assignable to a function type with more, which is why arr.map((x) => ...) works without accepting index and array.

    What interviewers listen for
    • Annotate parameters; return types inferred or explicit
    • Optional params after required; defaults imply optional
    • Rest parameters typed as arrays or tuples
    • Function type expressions and call signatures
    • Fewer parameters is assignable to more
  35. 35.What is an index signature, and what does the noUncheckedIndexedAccess option change?mid

    An index signature types an object used as a dictionary whose keys aren't known in advance: { [name: string]: number }, which is equivalent to Record<string, number>. Keys can be string, number, symbol or template literal patterns such as data-${string}, and any named properties must be compatible with the signature's value type.

    The catch: by default, scores['bob'] is typed number even though the key may not exist, so you get undefined at runtime with no warning. The same applies to arrays: list[10] is typed number.

    noUncheckedIndexedAccess adds | undefined to every index access, forcing a check before use. It isn't part of strict, so you opt in explicitly. It's a bit noisy with index-based loops, which is a good excuse for for...of.

    Alternatives: a Map, whose get already returns V | undefined. And note Record<'light' | 'dark', string> with literal keys is not an index signature: it's a fixed set of required properties.

    type Scores = { [name: string]: number }; // same as Record<string, number>
    const scores: Scores = { ada: 10 };
    
    const bob = scores['bob']; // number by default, but undefined at runtime!
    bob.toFixed();             // crashes; flagged under noUncheckedIndexedAccess
    
    const list = [1, 2, 3];
    const tenth = list[10];    // number (number | undefined with the flag)
    
    type Theme = Record<'light' | 'dark', string>; // both keys required
    const theme: Theme = { light: '#fff' };       // Error: 'dark' is missing
    What interviewers listen for
    • { [key: string]: T } types dictionary-like objects
    • Equivalent to Record<string, T>
    • Default: index access assumes the key exists
    • noUncheckedIndexedAccess adds | undefined; not in strict
    • Record with literal keys means required properties
  36. 36.Which tsconfig.json options do you consider essential, and what do they control?mid

    The ones I always look at:

    • strict, ideally plus noUncheckedIndexedAccess.
    • target: which JavaScript syntax the compiler emits. lib: which built-in type declarations exist, like DOM or ES2022 (derived from target if you don't set it).
    • module and moduleResolution: how imports are emitted and resolved. bundler suits Vite or webpack apps; nodenext suits code run by Node, and respects package.json exports and type.
    • noEmit when a bundler or esbuild/SWC does the transpiling and tsc only type-checks. Then isolatedModules or verbatimModuleSyntax ensures each file can be compiled on its own.
    • jsx: react-jsx for React.
    • paths for import aliases. It only affects type-checking and resolution, the bundler needs the same aliases.
    • skipLibCheck for faster builds; declaration, outDir and sourceMap for libraries.
    • include/exclude, extends for shared base configs, and references for project references in monorepos.
    What interviewers listen for
    • strict on, plus noUncheckedIndexedAccess if possible
    • target controls emitted syntax; lib the available types
    • module/moduleResolution: bundler or nodenext
    • noEmit plus isolatedModules when a bundler transpiles
    • paths doesn't rewrite imports; configure the bundler too
  37. 37.How do you type data coming from an API? Is const user = (await res.json()) as User good enough?mid

    Not really. res.json() returns Promise<any>, and the as only tells the compiler to trust you. If the backend changes a field, returns an error payload, sends null or sends dates as strings, TypeScript can't know, and the crash happens later, far from the fetch. Types are erased, so the only way to know the shape is to validate at runtime, at the boundary.

    Options, from lightest to strongest:

    • Treat the payload as unknown and narrow it with hand-written type guards. Fine for small shapes.
    • Use a schema library such as zod: define the schema once, parse (or safeParse) at the boundary, and derive the static type with z.infer, so the type and the validation can't drift apart.
    • Generate types from an OpenAPI or GraphQL schema. That keeps compile-time types matched to the contract, but still doesn't check what actually arrives unless paired with validation.

    A generic fetchJson<T>(url): Promise<T> helper is just a cast in disguise unless it also takes a validator.

    import { z } from 'zod';
    
    const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().nullable() });
    type User = z.infer<typeof UserSchema>; // { id: number; name: string; email: string | null }
    
    async function getUser(id: number): Promise<User> {
      const res = await fetch(`/api/users/${id}`);
      return UserSchema.parse(await res.json()); // throws if the shape is wrong
    }
    What interviewers listen for
    • res.json() is any; as User checks nothing
    • Types are erased, so validate at the boundary
    • Start from unknown and narrow, or use a schema
    • zod: one schema, parse at runtime, z.infer for the type
    • Generic fetchJson<T> without validation is a hidden cast
  38. 38.What is a distributive conditional type, and how do you prevent distribution?hard

    When a conditional type checks a naked type parameter, as in T extends U ? X : Y, and T is instantiated with a union, the conditional is applied to each member separately and the results are unioned. So with ToArray<T> = T extends unknown ? T[] : never, ToArray<string | number> is string[] | number[], not (string | number)[]. That's exactly what makes Exclude<T, U> = T extends U ? never : T work: members that map to never vanish from the union.

    To turn distribution off, wrap both sides in a one-element tuple: [T] extends [U] ? X : Y. Now the union is checked as a whole.

    Gotchas:

    • never is the empty union, so a distributive conditional given never returns never without evaluating either branch. A correct IsNever needs [T] extends [never].
    • boolean is true | false, so it distributes too.
    • Distribution only happens for a type parameter, not for a union written directly in the extends check.
    type ToArray<T> = T extends unknown ? T[] : never;
    type A = ToArray<string | number>;         // string[] | number[]
    
    type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
    type B = ToArrayNonDist<string | number>;  // (string | number)[]
    
    type MyExclude<T, U> = T extends U ? never : T;
    type C = MyExclude<'a' | 'b' | 'c', 'a'>;  // 'b' | 'c'
    
    type IsNeverWrong<T> = T extends never ? true : false;
    type IsNever<T> = [T] extends [never] ? true : false;
    type D = [IsNeverWrong<never>, IsNever<never>]; // [never, true]
    What interviewers listen for
    • Naked type parameter plus union: applied per member
    • Results are unioned back together
    • Powers Exclude, Extract and NonNullable
    • Wrap in [T] extends [U] to disable distribution
    • never input yields never; boolean distributes too
  39. 39.What are template literal types? Give a practical use case.hard

    Template literal types use template string syntax at the type level to build string literal types. When a placeholder holds a union, the result contains every combination: two sizes times two colors gives four class names. Placeholders can also be string or number to describe a pattern, like #${string} for hex colors or ${number}px for pixel values. The intrinsic helpers Uppercase, Lowercase, Capitalize and Uncapitalize transform them.

    Practical uses:

    • Event handler names, such as mapping 'click' to onClick with key remapping in a mapped type.
    • Design-system class names or CSS values that must follow a pattern.
    • Parsing strings with infer, for example extracting id from a route like '/users/:id' to type route params.

    The caveat is size: combinations multiply quickly, and big cross products slow the checker down; past a certain union size the compiler gives up with an error.

    type Size = 'sm' | 'lg';
    type Color = 'red' | 'blue';
    type ClassName = `${Size}-${Color}`; // 'sm-red' | 'sm-blue' | 'lg-red' | 'lg-blue'
    type EventName = 'click' | 'focus';
    type Handlers = { [E in EventName as `on${Capitalize<E>}`]: () => void }; // onClick, onFocus
    
    type Pixels = `${number}px`;
    const w: Pixels = '12px';
    const h: Pixels = '12em'; // Error: doesn't match the pattern
    
    type Param<T> = T extends `${string}:${infer P}` ? P : never;
    type Id = Param<'/users/:id'>; // 'id'
    What interviewers listen for
    • Template string syntax at the type level
    • Unions in placeholders expand to every combination
    • string/number placeholders describe string patterns
    • Pair with infer to parse strings, as to rename keys
    • Large cross products hurt compiler performance
  40. 40.What is declaration merging, and how do you use module augmentation to extend a third-party library's types?hard

    Declaration merging means the compiler combines several declarations with the same name into one. Interfaces merge their members; namespaces merge with other namespaces and with classes, functions and enums (for example, to attach static properties to a function); enums merge with each other. Type aliases never merge.

    Module augmentation uses this to extend types you don't own. In a file that is itself a module (it has an import or export), you write declare module 'library-name' { ... } and re-declare the library's interface with extra members, which merge into the original. Everyday examples: adding requiresAuth to Vue Router's RouteMeta, adding user to Express's request type, or typing Vite's ImportMetaEnv. For globals, declare global { interface Window { ... } } does the same thing.

    The limits: you can only add to or merge with existing declarations, not replace them or add new top-level declarations to the module, and a repeated property must keep the same type.

    import 'vue-router';
    
    declare module 'vue-router' {
      interface RouteMeta {
        requiresAuth?: boolean; // merged into the library's interface
      }
    }
    
    declare global {
      interface Window { dataLayer: unknown[] }
    }
    window.dataLayer.push({ event: 'signup' }); // now typed
    What interviewers listen for
    • Same-named declarations merge into one
    • Interfaces and namespaces merge; type aliases don't
    • declare module 'lib' re-opens a library's interfaces
    • Augmenting file must be a module itself
    • declare global extends globals like Window
  41. 41.What are excess property checks, and why does passing the same object through a variable sometimes avoid the error?mid

    Structural typing normally allows extra properties. The exception is a fresh object literal, one written directly where a type is expected: TypeScript reports any property the target type doesn't declare. draw({ colour: 'red' }) fails for an Options type with color?, which catches typos that would otherwise pass silently because every property is optional.

    Once the literal is stored in an unannotated variable and passed later, it's no longer fresh, so ordinary structural rules apply and extra properties are fine. That's why the "same" object can compile through a variable and fail inline. It's a targeted heuristic, not exact types, which TypeScript doesn't have.

    A related rule is weak type detection: if every property of the target is optional, an object sharing no properties with it is rejected even through a variable.

    To pass extra properties on purpose you can annotate the variable, add an index signature to the target, or spread the object, but usually the error is pointing at a real bug.

    interface Options { color?: string; width?: number }
    function draw(opts: Options) {}
    
    draw({ colour: 'red' });   // Error: 'colour' does not exist in type 'Options'
    
    const o = { colour: 'red', width: 2 };
    draw(o);                   // OK: not fresh, and it shares 'width'
    
    const typo = { colour: 'red' };
    draw(typo);                // Error: no properties in common (weak type)
    What interviewers listen for
    • Only fresh object literals get excess property checks
    • Catches typos, especially in optional properties
    • Via an unannotated variable, extra properties are allowed
    • Weak types reject objects with no overlapping properties
    • A heuristic, not exact types
  42. 42.What is the difference between the types object, Object and {}?mid
    • {} does not mean "empty object". It means any value except null and undefined: strings, numbers and booleans all qualify, because property access works on them. In fact unknown behaves like {} | null | undefined.
    • Object (capital O) is the type of Object.prototype's members. It behaves almost the same as {}, primitives included, and linters flag it as a mistake.
    • object (lowercase) means any non-primitive: plain objects, arrays, functions, class instances. It rejects string, number, boolean, symbol, bigint, null and undefined.

    What to use instead:

    • A truly empty object: Record<string, never>.
    • "Some object with unknown contents": Record<string, unknown> or object.
    • "Any non-nullish value" as a generic constraint: T extends {}.

    The mistake interviewers look for is writing {} or Object to mean "an object" and then being surprised that 'hello' is accepted.

    What interviewers listen for
    • {} accepts any non-nullish value, including primitives
    • Object behaves like {}; avoid it
    • object means non-primitive values only
    • unknown behaves like {} | null | undefined
    • Record<string, never> for a truly empty object
  43. 43.What is the type of the error in a catch block, and how should you handle it?mid

    JavaScript can throw anything, not just Error instances: strings, numbers, plain objects, even undefined. So TypeScript can't know what you'll catch. It used to be any; with useUnknownInCatchVariables, which is part of strict, the catch variable is unknown and must be narrowed before use.

    You can't annotate it with a specific type: catch (e: Error) is a compile error, only any or unknown are allowed, because TypeScript can't guarantee what was thrown.

    The idioms:

    • if (err instanceof Error) to access message and stack, with a fallback like String(err) for anything else.
    • A small getErrorMessage(err: unknown) helper used everywhere.
    • Custom error subclasses plus instanceof for specific handling.

    Watch out for promises: the reason in .catch((reason) => ...) is typed any by the standard library, so annotate it as unknown yourself.

    What interviewers listen for
    • Anything can be thrown, not only Error
    • unknown under strict (useUnknownInCatchVariables)
    • Only any or unknown annotations are allowed
    • Narrow with instanceof Error and a fallback
    • Promise .catch reason is any; annotate it unknown
  44. 44.What is import type, and why do options like isolatedModules and verbatimModuleSyntax care about it?mid

    import type { User } from './user' imports only type information. It's always removed from the output and can't be used as a value. The inline form, import { type User, createUser } from './user', marks individual names.

    Why it matters: tsc knows which imports are only used as types and drops them. But tools that transpile one file at a time (esbuild, SWC, Babel, and the bundlers built on them) can't look into ./user to see whether User is a type or a value. They might keep an import of something that doesn't exist at runtime, or drop an import that was needed for its side effects.

    • isolatedModules makes tsc report code that single-file transpilers can't handle safely, such as re-exporting a type without export type.
    • verbatimModuleSyntax makes the rule simple and predictable: imports without type are kept exactly as written, imports with type are dropped. So every type-only import must be marked.

    The result is output you can predict from the source, whatever tool compiles it.

    What interviewers listen for
    • import type is always erased from the output
    • Inline form: import { type User, createUser }
    • Per-file transpilers can't tell types from values
    • isolatedModules flags unsafe patterns like type re-exports
    • verbatimModuleSyntax: unmarked imports are kept as written
  45. 45.What is the difference between // @ts-ignore and // @ts-expect-error, and when is either acceptable?easy

    Both comments suppress type errors on the next line.

    • @ts-ignore suppresses silently, whether or not there is an error.
    • @ts-expect-error suppresses too, but is itself reported as an error ("Unused '@ts-expect-error' directive") when the next line has no error.

    That makes @ts-expect-error self-cleaning: when a library update or refactor fixes the underlying problem, the compiler tells you to remove the comment instead of leaving a stale suppression that might hide a future bug. Add a reason after it, like a link to the upstream issue.

    Acceptable uses are narrow: tests that deliberately pass invalid input, or working around a known bug in third-party types. Both are blunt instruments that hide every error on that line, not just the one you meant. Related directives: // @ts-nocheck disables checking for a whole file (handy mid-migration) and // @ts-check enables checking in a JavaScript file. Normally, fix the types or narrow instead.

    What interviewers listen for
    • Both suppress errors on the next line only
    • @ts-expect-error errors if nothing needs suppressing
    • Prefer @ts-expect-error: it can't go stale
    • Explain why, e.g. link the upstream issue
    • Use sparingly: tests and known third-party bugs
  46. 46.How would you migrate a large JavaScript codebase to TypeScript?mid

    Incrementally, never as a big-bang rewrite.

    • Set up side by side: add a tsconfig.json with allowJs so JavaScript and TypeScript compile together, and noEmit if the bundler does the transpiling. Add a type-check step to CI early.
    • Get value before renaming: checkJs or a per-file // @ts-check plus JSDoc types gives checking in plain .js files.
    • Convert leaves first: utilities, the API client and shared models are imported everywhere, so typing them makes types flow outward to their callers.
    • Dependencies: install @types/* packages and write small .d.ts shims for untyped ones.
    • Tighten gradually: start loose, then enable noImplicitAny, then strictNullChecks, then full strict, fixing errors per directory.
    • Prevent regressions: lint rules against explicit any, and track the remaining count of any and @ts-expect-error as a metric.

    Prioritize boundaries, such as API responses, component props and shared domain types, because that's where types pay off most. Temporary any is fine as long as it's visible and shrinking.

    What interviewers listen for
    • Incremental: allowJs, JS and TS side by side
    • checkJs and JSDoc for checking before renaming
    • Convert leaf modules and shared types first
    • Tighten strict flags one at a time
    • CI type-checks and tracked any count prevent regressions
  47. 47.What are default type parameters, and how do they interact with type inference?mid

    A default type parameter works like a default function argument: with interface ApiResponse<T = unknown>, writing plain ApiResponse means ApiResponse<unknown>. The rules mirror optional parameters: defaulted type parameters come after required ones, a default must satisfy the parameter's constraint, and it can refer to earlier parameters, as in <A, B = A>.

    With function calls, inference wins: a default is only used when there's nothing to infer from.

    The subtle part is explicit type arguments, which are all-or-nothing. TypeScript has no partial inference. If you write pair<number>(1, 'x') for pair<A, B = A>, B isn't inferred from 'x': it takes its default, number, and the call fails. And for a function with two parameters and no defaults, passing only one type argument is an error ("Expected 2 type arguments").

    Defaults are common in library types, such as event emitters with a default event map or generic components with a default item type.

    What interviewers listen for
    • <T = Default> used when no type argument is given
    • Defaults go last and must satisfy the constraint
    • Inference takes priority over defaults in calls
    • Explicit type arguments are all-or-nothing
    • Unspecified parameters take defaults, not inferred types
  48. 48.What are branded (nominal) types, and why would you use them?hard

    Because TypeScript is structural, type UserId = string is only an alias: any string, including an order id, can be passed where a UserId is expected.

    A brand adds a property that exists only in the type system: type UserId = string & { readonly __brand: 'UserId' }. A plain string no longer satisfies it, and UserId and OrderId aren't interchangeable. At runtime it's still an ordinary string with zero overhead, and a UserId can still be used anywhere a string is expected.

    Branded values are created in one place, a "smart constructor" or validation function, which is the only spot that needs an as. From then on the type proves the value went through that check.

    Good uses: IDs, validated values (Email, NonEmptyString), units (Meters vs Feet) and sanitized HTML. Some teams use a unique symbol as the brand key so it can't clash, and schema libraries support it directly, such as zod's .brand().

    type Brand<T, B extends string> = T & { readonly __brand: B };
    type UserId = Brand<string, 'UserId'>;
    type OrderId = Brand<string, 'OrderId'>;
    function toUserId(raw: string): UserId {
      if (!raw.startsWith('u_')) throw new Error('Invalid user id');
      return raw as UserId; // the only assertion, behind validation
    }
    function getUser(id: UserId) {}
    
    getUser(toUserId('u_42')); // OK
    getUser('u_42');           // Error: a plain string isn't a UserId
    getUser('o_1' as OrderId); // Error: an OrderId isn't a UserId
    What interviewers listen for
    • Structural typing makes same-shaped aliases interchangeable
    • Intersect with a type-only brand property
    • Zero runtime cost; still usable as the base type
    • Create through a single validating constructor
    • Good for IDs, validated strings and units
  49. 49.Explain covariance and contravariance in TypeScript. What is method parameter bivariance?hard

    Variance describes how subtyping between two types carries over to types built from them. Say Dog is a subtype of Animal:

    • Covariant (same direction): return types and readonly properties. () => Dog is assignable to () => Animal.
    • Contravariant (reversed): function parameters. (a: Animal) => void is assignable to (d: Dog) => void, because a function that handles any animal can handle a dog, but not the other way round. strictFunctionTypes, part of strict, enforces this for function types.
    • Bivariant: parameters of methods, declared with method syntax like handle(a: Animal): void, are still checked in both directions, mainly so generic types like Array<T> keep relating covariantly. That's unsound, so declare callbacks with property syntax, handle: (a: Animal) => void, to get strict checking.

    Arrays are another deliberate hole: Dog[] is assignable to Animal[] even though they're mutable, so you could push a Cat through the Animal[] alias. You can document variance explicitly with in and out modifiers on type parameters, such as interface Producer<out T>.

    class Animal { name = '' }
    class Dog extends Animal { bark() {} }
    
    let handleAnimal = (a: Animal) => {};
    let handleDog = (d: Dog) => d.bark();
    handleDog = handleAnimal; // OK: parameters are contravariant
    handleAnimal = handleDog; // Error under strictFunctionTypes
    
    interface MethodStyle { handle(a: Animal): void }
    interface PropStyle { handle: (a: Animal) => void }
    const m: MethodStyle = { handle: (d: Dog) => d.bark() }; // OK: method params are bivariant
    const p: PropStyle = { handle: (d: Dog) => d.bark() };   // Error: checked strictly
    What interviewers listen for
    • Covariant: return types, readonly properties
    • Contravariant: function parameters under strictFunctionTypes
    • Method-syntax parameters stay bivariant (unsound)
    • Use property syntax for strictly checked callbacks
    • Arrays are covariant despite mutability
  50. 50.How do you write recursive types, for example a JSON type or a DeepPartial?hard

    A type alias or interface can refer to itself, as long as the reference sits inside a structure such as an object property, an array or a generic, rather than being an immediate cycle. Common examples:

    • Trees: interface TreeNode { value: string; children: TreeNode[] }.
    • A Json union where arrays and objects contain Json again.
    • Recursive mapped types like DeepPartial or DeepReadonly, which apply themselves to each nested property.

    Recursive conditional types work too, for example flattening nested arrays or parsing strings with template literal types.

    Things to get right:

    • Decide what to do with functions, Date, Map and so on. A naive DeepPartial maps over a function's properties instead of leaving it alone.
    • The compiler has a recursion depth limit; hitting it gives "Type instantiation is excessively deep and possibly infinite". Tail-recursive conditional types get a higher limit.
    • Deep recursion on big types slows the checker and the editor, so keep recursive helpers simple.
    type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
    
    type DeepPartial<T> = T extends (...args: any[]) => unknown
      ? T
      : T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
    
    interface Settings { theme: { mode: 'light' | 'dark'; accent: string }; tags: string[] }
    const patch: DeepPartial<Settings> = { theme: { mode: 'dark' } }; // nested fields optional
    
    const ok: Json = { user: { name: 'Ada', tags: ['x'] } };
    const bad: Json = { when: new Date() }; // Error: a Date isn't JSON
    What interviewers listen for
    • Self-reference must go through a property, array or generic
    • Trees, JSON and nested configs are classic cases
    • DeepPartial/DeepReadonly recurse via mapped types
    • Handle functions, Date and arrays deliberately
    • Depth limits and checker cost for deep recursion
  51. 51.What are decorators in TypeScript, and what should you know before using them?hard

    A decorator is a function applied with @name to a class or a class member (method, accessor, field) that can observe, wrap or replace it. Typical uses are logging or timing methods, validation, registering routes or components, and dependency injection.

    There are two incompatible flavors:

    • Standard decorators, following the TC39 proposal, work without any compiler flag. A decorator receives the decorated value plus a context object with its kind, name and helpers like addInitializer. They can't decorate parameters.
    • Legacy decorators, enabled with experimentalDecorators, have a different signature (target, property key, descriptor), support parameter decorators, and together with emitDecoratorMetadata emit runtime type metadata for dependency injection. Angular, NestJS and TypeORM were built on this flavor.

    Cautions: a library's decorators only work in the mode they were written for; decorators emit runtime code, so they aren't erasable syntax; and they only apply to classes and class members, not standalone functions. Outside a framework that expects them, a plain higher-order function is usually simpler.

    What interviewers listen for
    • Functions applied with @ to classes and class members
    • Standard decorators need no flag and receive a context
    • experimentalDecorators is the legacy, incompatible flavor
    • Parameter decorators and metadata only in legacy mode
    • Not for standalone functions; they emit runtime code

    Likely follow-up: How would you write a decorator that logs how long a method takes?

  52. 52.Why does Object.keys(obj) return string[] instead of an array of obj's keys?hard

    Because of structural typing, an object can have more properties than its type declares. A value typed { name: string } might really be { name: 'Ada', password: '...' }, assigned from a variable with extra fields. If Object.keys returned 'name'[], that would be a lie, so string[] is the honest type. for...in gives string keys for the same reason.

    The consequence is that obj[key] with key: string fails under strict ("expression of type 'string' can't be used to index type ..."), because the object type has no index signature.

    Options:

    • When you know the object has exactly that shape, typically a literal you created yourself, cast the keys: Object.keys(obj) as Array<keyof typeof obj>, ideally inside a small keysOf helper so the assumption lives in one place.
    • Iterate over a separate as const array of the keys you care about.
    • Use a Map or a Record<K, V> with a known key union when the data really is a dictionary.
    const user = { name: 'Ada', role: 'admin' };
    for (const key of Object.keys(user)) {
      user[key]; // Error: 'string' can't be used to index this type
    }
    
    const keys = Object.keys(user) as Array<keyof typeof user>; // fine: we built user
    keys.forEach((k) => user[k]); // k: 'name' | 'role'
    
    type Named = { name: string };
    const extra = { name: 'Ada', password: 'hunter2' };
    const n: Named = extra; // legal, so Object.keys(n) includes 'password'
    What interviewers listen for
    • Objects may have more keys than their type declares
    • So Object.keys honestly returns string[]
    • Indexing with a string fails without an index signature
    • Cast to Array<keyof T> only when the shape is exact
    • Prefer a const key list, a Map or a Record
  53. 53.How would you type an event emitter so that on('login', handler) knows the payload type of each event?hard

    Describe the events as a map from event name to payload type, and make the emitter generic over that map:

    • on<K extends keyof E>(event: K, handler: (payload: E[K]) => void)
    • emit<K extends keyof E>(event: K, payload: E[K])

    When you call bus.on('login', ...), K is inferred as the literal 'login' and the indexed access E[K] looks up its payload type. A misspelled event name or a wrong payload is a compile error, and the handler's parameter is inferred without an annotation.

    Internally the handler storage is a mapped type, { [K in keyof E]?: Array<(payload: E[K]) => void> }, so each list is typed per event.

    Refinements worth mentioning: events without a payload can use a rest-tuple parameter, ...args: E[K] extends void ? [] : [E[K]], so emit('logout') needs no second argument; off and once follow the same pattern; and in more complex implementations a small internal cast is acceptable as long as the public API stays strict.

    type Events = { login: { userId: string }; error: Error };
    class Emitter<E> {
      private handlers: { [K in keyof E]?: Array<(payload: E[K]) => void> } = {};
      on<K extends keyof E>(event: K, handler: (payload: E[K]) => void) {
        (this.handlers[event] ??= []).push(handler);
      }
      emit<K extends keyof E>(event: K, payload: E[K]) { this.handlers[event]?.forEach((h) => h(payload)); }
    }
    const bus = new Emitter<Events>();
    bus.on('login', (p) => console.log(p.userId)); // p: { userId: string }
    bus.emit('login', { id: 1 }); // Error: payload must be { userId: string }
    bus.on('logn', () => {});     // Error: not an event name
    What interviewers listen for
    • Event map type: name to payload
    • K extends keyof E infers the event name literal
    • E[K] looks up the payload for that event
    • Mapped type for per-event handler storage
    • Rest tuple for events without payloads
  54. 54.What are variadic tuple types, and where are they useful?hard

    Variadic tuple types allow generic spreads inside tuple types, as in [...T, ...U] where T and U are tuple type parameters. That lets you type operations that preserve the exact length and element types of tuples instead of collapsing them into arrays:

    • Concatenating, prepending or appending: a concat(a, b) that returns [...T, ...U].
    • Functions that manipulate argument lists: partial application, currying, bind, and wrappers or middleware that forward ...args to another function while keeping its parameter types.

    Writing a parameter as [...T] rather than T hints the compiler to infer a tuple instead of an array. Rest elements can also appear anywhere in a tuple, not only at the end, as in [...string[], number].

    This is what makes a type-safe partial(fn, 'Hello') possible: the compiler splits fn's parameter list into the part you supplied and the part the returned function still needs, parameter names included.

    function concat<T extends unknown[], U extends unknown[]>(a: [...T], b: [...U]): [...T, ...U] {
      return [...a, ...b];
    }
    const t = concat([1, 'a'], [true]); // [number, string, boolean]
    
    function partial<A extends unknown[], B extends unknown[], R>(
      fn: (...args: [...A, ...B]) => R, ...first: A
    ): (...rest: B) => R {
      return (...rest) => fn(...first, ...rest);
    }
    const greet = (greeting: string, name: string, punctuation: string) => greeting + ', ' + name + punctuation;
    const hello = partial(greet, 'Hello'); // (name: string, punctuation: string) => string
    What interviewers listen for
    • Generic spreads in tuples: [...T, ...U]
    • Preserve tuple length and element types
    • Type argument forwarding: partial application, wrappers
    • A [...T] parameter hints tuple inference
    • Rest elements allowed anywhere in a tuple
  55. 55.What does the const modifier on a type parameter do, as in function definePages<const T>(pages: T)?hard

    It makes inference for that type parameter behave as if the caller had written as const on the argument: literal types are kept, array literals are inferred as tuples, and object properties keep their literal types.

    Without it, definePages(['home', 'about']) infers string[] and the specific names are lost. With <const T extends readonly string[]> it infers readonly ['home', 'about'], so the library can derive a union like T[number] and reject unknown page names elsewhere.

    It's aimed at library APIs where literal values matter, such as route definitions, event names, config builders or query builders, so callers get precise types without having to remember as const at every call site.

    The limit: it only affects expressions written directly in the call. Passing a variable that was already inferred as string[] still gives string[], because that variable's type was widened when it was declared.

    function plain<T extends readonly string[]>(names: T) { return names; }
    function literal<const T extends readonly string[]>(names: T) { return names; }
    
    const a = plain(['home', 'about']);   // string[]
    const b = literal(['home', 'about']); // readonly ['home', 'about']
    type Page = (typeof b)[number];       // 'home' | 'about'
    
    const list = ['home', 'about'];
    const c = literal(list);              // string[]: only call-site literals count
    What interviewers listen for
    • Infers as if the argument had as const
    • Keeps literal types and infers tuples
    • Lets libraries derive unions from caller literals
    • Callers don't need to write as const
    • Only affects literals written at the call site
  56. 56.Have you dealt with slow type-checking? What makes types expensive, and how do you diagnose it?hard

    Common causes:

    • Huge unions, especially template literal cross products, and deeply recursive conditional or mapped types.
    • Large intersections of object types where interface ... extends would do. Interface relationships are cached; intersections are often re-checked.
    • Big inferred return types that must be recomputed and written out into declaration files.
    • Library types with heavy generic inference, such as some styling, schema or ORM libraries.

    The symptoms are slow tsc runs, laggy autocomplete, and errors like "Type instantiation is excessively deep and possibly infinite".

    To diagnose, tsc --extendedDiagnostics reports check time, memory and the number of types and instantiations; --generateTrace writes a trace you can inspect to find the expensive files and types; and --explainFiles shows why each file is included at all.

    Fixes: prefer interfaces over intersections, add explicit return types to exported functions, name complex types instead of repeating them inline, shrink unions, cap recursion, enable skipLibCheck, and split large repos with incremental builds and project references.

    What interviewers listen for
    • Huge unions and deep recursive types are costly
    • Prefer interface extends over big intersections
    • Explicit return types avoid re-inferring and re-emitting
    • --extendedDiagnostics and --generateTrace to diagnose
    • skipLibCheck, incremental, project references at scale
esc