Most TypeScript interview prep online is a wall of syntax. Memorize Pick, memorize Partial, recite the difference between type and interface, and hope the interviewer doesn't ask anything that actually matters.

The questions seniors get asked are different. They're less "can you use a mapped type" and more "how do you model a payment state machine so invalid combinations don't compile". Less syntax, more judgement.

This guide is the version I'd want to read the night before an interview: the mental model, the senior-level talking points, the pitfalls that catch experienced engineers, and the answers that signal you've actually shipped TypeScript in production.

The Mental Model: Types Disappear At Runtime

If you take one thing into the interview, take this:

TypeScript is a compile-time tool. At runtime, your code is just JavaScript.

TypeScript src/users.ts
type User = {
  id: string;
  name: string;
};

async function getUser(): Promise<User> {
  const response = await fetch("/api/user");
  return response.json(); // ← lying. This is `any` pretending to be User.
}

The Promise<User> annotation tells your editor and the rest of your codebase what to expect. It does nothing to validate the response. If the API returns { id: 1, name: null }, TypeScript will not catch it.

Two-column reference titled &quot;TypeScript: Compile-Time vs Runtime&quot; listing what TypeScript does at compile time (static type checking, autocomplete, refactoring safety) versus what it does NOT do at runtime (validate API responses, check form input, verify env vars), with a pipeline showing TypeScript source compiled to plain JavaScript with types stripped.

Senior answer:

TypeScript protects internal code contracts. External boundaries like API responses, form input, env vars, webhook payloads, database results, and third-party SDKs still need runtime validation. I treat unknown data as unknown and parse it before trusting it.

This single idea unlocks half the senior-level questions you'll get.

type vs interface

Both describe object shapes. The differences:

TypeScript
// interface supports declaration merging
interface User {
  id: string;
}

interface User {
  name: string;
}

// User is now { id: string; name: string }
TypeScript
// type is more flexible for unions, intersections, mapped types,
// conditional types, template literals, and primitives
type Status = "idle" | "loading" | "success" | "error";
type Nullable<T> = T | null;

Senior answer:

I use interface for public object contracts that might be extended (especially when authoring libraries or augmenting external types), and type for unions, utility compositions, mapped types, and anything that isn't a plain object shape. In application code, the choice matters less than people pretend.

Module augmentation (the real reason interface merging matters)

Declaration merging isn't just a curiosity: it's how you extend types from third-party libraries you don't own:

TypeScript src/types/express.d.ts
import "express";

declare module "express" {
  interface Request {
    user?: User;
    requestId: string;
  }
}

After that file is loaded, req.user is typed correctly across your entire codebase. Same pattern works for process.env, Window, Vite's ImportMeta, and anywhere a library exposes a mergeable interface.

Senior answer:

I reach for module augmentation when I need to add fields to a third-party type, typically Express's Request, process.env, or Vite's ImportMetaEnv. It's one of the legitimate reasons I prefer interface over type for public contracts.

any, unknown, and never

This trio is one of the most common interview topics and one of the easiest places to sound senior.

any disables type checking

TypeScript
let value: any = "hello";
value.toUpperCase();
value.thisDoesNotExist();
value.foo.bar.baz; // all "fine"

any is contagious: it spreads through your code and silently kills type safety wherever it lands.

unknown is any with a seatbelt

TypeScript
let value: unknown = "hello";
value.toUpperCase(); // ❌ Error

if (typeof value === "string") {
  value.toUpperCase(); // ✅ narrowed
}

You must narrow before using it. That's the point.

never represents impossible values

The classic use case is exhaustiveness checking:

TypeScript src/payments/handle-status.ts
type PaymentStatus =
  | { type: "pending" }
  | { type: "paid"; paymentId: string }
  | { type: "failed"; reason: string };

function handleStatus(status: PaymentStatus): string {
  switch (status.type) {
    case "pending":
      return "Payment is pending";
    case "paid":
      return `Paid: ${status.paymentId}`;
    case "failed":
      return `Failed: ${status.reason}`;
    default: {
      const _exhaustive: never = status;
      throw new Error(`Unhandled status: ${JSON.stringify(_exhaustive)}`);
    }
  }
}

If someone adds | { type: "refunded" } later, the compiler will reject the default block until you handle the new case. That throw matters. Returning silently in production is how impossible states ship.

Three-column reference card titled &quot;any vs unknown vs never&quot; comparing each type&#39;s definition, when to use it, when to avoid it, and a small code snippet.

Senior answer:

I use unknown for any data crossing a boundary into my code, and never to make impossible cases visible at compile time. Exhaustive switches over discriminated unions are one of the highest-value patterns in TypeScript: they turn a future bug into a compile error.

Discriminated Unions: The Pattern That Matters Most

This is probably the single most important pattern to internalize for a senior interview.

The bad version

TypeScript
type RequestState<T> = {
  loading: boolean;
  error?: string;
  data?: T;
};

This allows nonsense:

TypeScript
{ loading: true, error: "Failed", data: user }

Loading and failed and successful, all at once. This kind of state shape is the cause of more frontend bugs than any other single thing.

The good version

TypeScript
type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

function render(state: RequestState<User>): string {
  switch (state.status) {
    case "idle":    return "Nothing yet";
    case "loading": return "Loading…";
    case "success": return state.data.name; // data is guaranteed
    case "error":   return state.error;     // error is guaranteed
  }
}

Invalid combinations don't compile. The narrowing inside each case is automatic.

Side-by-side comparison titled &quot;Modeling Request State&quot;, left panel shows the avoid pattern with boolean flags allowing impossible combinations, right panel shows the prefer pattern with a discriminated union of idle/loading/success/error variants.

Senior answer:

Discriminated unions let you make invalid states unrepresentable. That's the senior-level mindset shift: you're not annotating data, you're modeling a domain. If a combination of fields can't legally exist, the type system shouldn't allow it to be expressed.

Generics That Don't Suck

Generics confuse people because they look like magic. They're not: they're parameters for types.

Basic generics

TypeScript
function identity<T>(value: T): T {
  return value;
}

T is a placeholder. Whatever you pass in, that's what comes out.

Constraints

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

const user = { id: 1, name: "Nazar" };
const name = getProperty(user, "name");      // string
const id   = getProperty(user, "id");        // number
const bad  = getProperty(user, "wrongKey");  // ❌ Error

K extends keyof T says: K must be a valid key of T. T[K] says: the return type is whatever that property's type is.

This is the senior move, preserving the relationship between input and output, instead of returning any.

Three-step horizontal flow diagram titled &quot;How getProperty&lt;T, K extends keyof T&gt;(obj: T, key: K): T[K] Resolves&quot; walking through inputs, type inference, and output, with a compile-error example.

Generic React component

TSX src/components/Select.tsx
type SelectProps<T> = {
  items: T[];
  getLabel: (item: T) => string;
  onSelect: (item: T) => void;
};

export function Select<T>({ items, getLabel, onSelect }: SelectProps<T>) {
  return (
    <ul>
      {items.map((item, i) => (
        <li key={i} onClick={() => onSelect(item)}>
          {getLabel(item)}
        </li>
      ))}
    </ul>
  );
}

The component works for any item shape, and onSelect knows the exact type.

NoInfer (TS 5.4+)

A modern feature worth knowing:

TypeScript
function createState<T>(initial: T, fallback: NoInfer<T>): T {
  return Math.random() > 0.5 ? initial : fallback;
}

createState("hello", "world"); // T inferred from `initial`, fallback must match

Without NoInfer, both arguments would contribute to inferring T, sometimes in ways you don't want.

const type parameters (TS 5.0+)

TypeScript
function asTuple<const T extends readonly unknown[]>(arr: T): T {
  return arr;
}

const t = asTuple(["a", "b", "c"]);
// inferred as readonly ["a", "b", "c"], not string[]

Senior answer:

Generics aren't about clever abstractions. They're about preserving type relationships across boundaries, from input to output, from props to callbacks, from query to result. The win is that consumers get accurate types without me having to write them by hand.

Utility Types You Should Know Cold

Utility What it does
Partial<T> Makes all properties optional
Required<T> Makes all properties required
Readonly<T> Makes all properties readonly
Pick<T, K> Selects properties K from T
Omit<T, K> Removes properties K from T
Record<K, V> Object with keys K and values V
ReturnType<F> Return type of function F
Parameters<F> Parameter tuple of function F
Awaited<T> Unwraps Promise<T> to T
NonNullable<T> Removes null and undefined
Extract<T, U> Members of T assignable to U
Exclude<T, U> Members of T not assignable to U

Two patterns worth memorizing:

TypeScript
// Derive an input type from a domain type
type CreateUserInput = Omit<User, "id" | "createdAt">;

// Derive a service return type without redeclaring it
type FetchUser = Awaited<ReturnType<typeof fetchUser>>;

Reference grid titled &quot;TypeScript Utility Types: Cheatsheet&quot; with twelve cards covering Partial, Required, Readonly, Pick, Omit, Record, ReturnType, Parameters, Awaited, NonNullable, Extract, and Exclude.

Advanced Type Mechanics

Mapped types

TypeScript
type Mutable<T>  = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };

type Nullable<T> = { [K in keyof T]: T[K] | null };

The -? and -readonly syntax removes modifiers. The ? and readonly syntax adds them.

2x2 matrix titled &quot;Mapped Type Modifiers&quot; with axes ADD vs REMOVE and readonly vs optional, showing the four combinations of mapped-type modifier syntax.

Conditional types and infer

TypeScript
type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;
type ArrayItem<T>     = T extends readonly (infer Item)[] ? Item : never;

type A = UnwrapPromise<Promise<string>>;     // string
type B = ArrayItem<{ id: number }[]>;        // { id: number }

infer extracts a type from another type's structure. This is how libraries derive things like "the params of this React Query hook" or "the response type of this tRPC procedure."

Distributive conditional types (the senior-level gotcha)

When a conditional type's checked type is a naked type parameter, it distributes over unions:

TypeScript
type ToArray<T> = T extends unknown ? T[] : never;

type A = ToArray<string | number>;
// distributes: ToArray<string> | ToArray<number>
// = string[] | number[]   ← probably not what you wanted

To prevent distribution, wrap in a tuple:

TypeScript
type ToArrayBoxed<T> = [T] extends [unknown] ? T[] : never;

type B = ToArrayBoxed<string | number>;
// = (string | number)[]

This is also how NonNullable<T> and Exclude<T, U> work internally: they rely on distribution to filter union members.

Senior answer:

Distributive conditional types are great when you want to map across a union, and a footgun when you don't. The fix is the [T] extends [U] tuple wrap: that prevents distribution and treats the union as a single type.

Template literal types

TypeScript
type Entity = "user" | "product" | "order";
type Action = "created" | "updated" | "deleted";
type EventName = `${Entity}.${Action}`;
// "user.created" | "user.updated" | ... | "order.deleted"

Used heavily for event systems, route names, CSS class generators, and type-safe SQL builders.

as const and satisfies (the modern combo)

TypeScript
const ROUTES = {
  home: "/",
  users: "/users",
  settings: "/settings",
} as const satisfies Record<string, `/${string}`>;

type RouteName = keyof typeof ROUTES;
// "home" | "users" | "settings"

type RoutePath = (typeof ROUTES)[RouteName];
// "/" | "/users" | "/settings"

satisfies validates the shape against a contract without widening the type. Compare with the old way:

TypeScript
const ROUTES: Record<string, string> = {
  home: "/",
  users: "/users",
};

ROUTES.home; // string — literal type lost

Senior answer:

satisfies is one of the most underrated features added to TypeScript. It lets me validate config shapes, route maps, theme objects, and event registries against a contract while keeping the precise inferred types for downstream usage.

Runtime Boundaries: Where TypeScript Ends

This is the topic that separates juniors from seniors.

Architecture diagram titled &quot;Where TypeScript Ends: The Boundary Pattern&quot; showing trusted internal code surrounded by six external boundary zones (API response, form input, env vars, database result, webhook/queue, third-party SDK), each with a runtime validation gate.

Type guards

TypeScript
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    typeof (value as Record<string, unknown>).id === "string" &&
    typeof (value as Record<string, unknown>).name === "string"
  );
}

Hand-rolled guards work, but they're tedious and easy to get wrong.

Assertion functions (asserts)

A close cousin of type guards. Instead of returning true/false, an assertion function throws and TypeScript narrows everything after the call:

TypeScript
function assertIsUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new Error("Expected User");
  }
}

function processUser(value: unknown) {
  assertIsUser(value);
  // value is User from this line on, no `if` block needed
  console.log(value.name);
}

Useful when you'd rather throw than branch. Schema parsers like Zod's .parse() are essentially assertion functions under the hood.

The filter type predicate pattern

This shows up in production code constantly, and it's one of the cleanest ways to demonstrate fluency:

TypeScript
const items: (User | null)[] = [user1, null, user2];

// ❌ Doesn't narrow — still (User | null)[]
const bad = items.filter((x) => x !== null);

// ✅ Type predicate narrows to User[]
const good = items.filter((x): x is User => x !== null);

The (x): x is User return-type annotation is what tells TypeScript the filter actually narrows the array.

unknown in catch blocks

With useUnknownInCatchVariables (or strict), caught errors are typed as unknown, not any:

TypeScript
try {
  await doThing();
} catch (err) {
  // err is unknown — must narrow
  if (err instanceof Error) {
    console.log(err.message);
  } else {
    console.log("Unknown error", err);
  }
}

This catches the very common bug of assuming everything thrown is an Error. In JavaScript, anything can be thrown: strings, numbers, even undefined.

Validation libraries (the modern answer)

In production codebases the answer to "how do you type API responses" is almost always Zod, Valibot, or ArkType:

TypeScript src/schemas/user.ts
import { z } from "zod";

export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user", "guest"]),
});

export type User = z.infer<typeof UserSchema>;

export async function getUser(): Promise<User> {
  const res = await fetch("/api/user");
  const json: unknown = await res.json();
  return UserSchema.parse(json); // throws on invalid data
}

The pattern to internalize: the schema is the source of truth, and the type is derived from it. One source, no drift between runtime check and type.

Reference diagram titled &quot;Type Narrowing in TypeScript: Five Techniques&quot; with a vertical stack of cards covering typeof, instanceof, the in operator, user-defined type guards, and assertion functions, plus a callout for discriminated-union narrowing.

Senior answer:

I derive types from runtime schemas, not the other way around. With z.infer<typeof Schema>, the validation logic and the static type are guaranteed to stay in sync. I treat every external boundary (API, form, env, queue, third-party) as unknown until parsed.

Function Overloads vs Generics

Sometimes the input/output relationship needs more than a single signature. Two tools:

Overloads: different shapes for different inputs

TypeScript
function parseValue(value: string): string;
function parseValue(value: number): number;
function parseValue(value: string | number): string | number {
  return typeof value === "string" ? value.trim() : value * 2;
}

Generics: when one shape preserves through

TypeScript
function wrap<T>(value: T): T[] {
  return [value];
}

Senior answer:

I reach for overloads when the input/output relationship genuinely changes shape, for example by returning different types based on a flag argument. For everything else, generics are cleaner. Overloads are easy to misuse and hard to read once you have more than two of them.

tsconfig.json for Senior Engineers

Knowing the strictness flags signals experience:

JSON tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noPropertyAccessFromIndexSignature": true,
    "useUnknownInCatchVariables": true
  }
}

The two most impactful beyond strict:

noUncheckedIndexedAccess: array and record access returns T | undefined:

TypeScript
const users: string[] = ["Nazar"];
const first = users[0]; // string | undefined, not string

This catches more real bugs than almost any other flag.

exactOptionalPropertyTypes: name?: string means "may be missing", not "may be undefined":

TypeScript
type User = { name?: string };

const u: User = { name: undefined }; // ❌ with exact, ✅ without

Senior answer:

I turn on strict from day one. Adding it to a large existing codebase is painful, so I'd rather pay the cost upfront. noUncheckedIndexedAccess in particular is non-negotiable for me: it surfaces a class of bugs that otherwise only show up in production.

Senior-Level Topics You Should Be Ready For

Structural vs nominal typing

TypeScript is structural: types are compatible if their shapes are compatible, regardless of name.

TypeScript
type User     = { id: string; name: string };
type Employee = { id: string; name: string };

const u: User = {} as Employee; // ✅ — shapes match

Compare to Java/C#, where two classes with identical fields are still incompatible.

Branded (nominal) types

When you want nominal-style safety in a structural system:

TypeScript
type Brand<T, B> = T & { __brand: B };

type UserId  = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

function getUser(id: UserId)  { /* … */ }
function getOrder(id: OrderId) { /* … */ }

const uid = "abc" as UserId;
const oid = "xyz" as OrderId;

getUser(oid); // ❌ Error — different brand

Useful for IDs, sanitized strings, currency amounts in different units, and anything where mixing two values of the same primitive type would be a bug.

enum vs union of literals

Most senior codebases avoid enum in favor of unions:

TypeScript
// Avoid
enum Role { Admin, User, Guest }

// Prefer
type Role = "admin" | "user" | "guest";

Reasons: numeric enums have weird reverse-mapping behavior, const enum interacts badly with isolatedModules, and unions compose better with template literals and mapped types. enum does emit runtime code; unions don't.

Variance (covariance and contravariance)

Worth understanding even if you don't get asked directly:

  • Covariance: a Dog[] is assignable to Animal[] (function return positions)
  • Contravariance: a function taking Animal is assignable where a function taking Dog is expected (function parameter positions)

The classic gotcha:

TypeScript
type Handler<T> = (event: T) => void;

const handleAnimal: Handler<{ kind: string }> = (e) => console.log(e.kind);
const handleDog: Handler<{ kind: string; breed: string }> = handleAnimal;
// ✅ allowed — but if handleDog is called with a Dog, the body might
// access .breed and the assigned handler doesn't have it.

TypeScript treats function parameters as bivariant by default for ergonomics, which is technically unsound. The strictFunctionTypes flag (on with strict) enforces contravariance for function-type expressions but not method syntax: a footgun worth knowing about.

Conceptual diagram titled &quot;Variance: When Are Function Types Substitutable?&quot; showing covariance in return positions, contravariance in parameter positions, and a warning about method-syntax bivariance.

Recursive types

TypeScript
type Json =
  | string
  | number
  | boolean
  | null
  | Json[]
  | { [key: string]: Json };

type TreeNode<T> = {
  value: T;
  children: TreeNode<T>[];
};

Type-only imports

TypeScript
import type { User } from "./types";
import { type Logger, createLogger } from "./logger";

These are stripped at compile time: they don't generate runtime imports. Important for tree-shaking, circular-dependency avoidance, and isolatedModules compatibility (which bundlers like esbuild and SWC require).

Common Pitfalls Interviewers Like To Probe

Object.keys returns string[], not keyof T:

TypeScript
const user = { id: "1", name: "Nazar" };
Object.keys(user).forEach((k) => {
  user[k]; // ❌ Error — k is string, not keyof typeof user
});

This is intentional: TypeScript can't prove the object doesn't have extra keys at runtime due to structural typing.

The void return type footgun:

TypeScript
type Callback = () => void;

const cb: Callback = () => 42; // ✅ allowed!

void in a callback type means "I don't care what you return", not "you must return nothing." This trips people up with Array.prototype.forEach and similar APIs.

Excess property checks only fire on object literals:

TypeScript
type User = { id: string; name: string };

const direct: User = { id: "1", name: "Nazar", age: 30 };  // ❌
const raw = { id: "1", name: "Nazar", age: 30 };
const indirect: User = raw;                                // ✅

Empty interface vs Record<string, never>:

TypeScript
interface Empty {}                       // assignable from almost anything
type EmptyObj = Record<string, never>;   // truly empty

@ts-expect-error vs @ts-ignore:

TypeScript
// @ts-ignore       — silences forever, even if the underlying error goes away
// @ts-expect-error — silences now, but errors if the underlying error is fixed

Always prefer @ts-expect-error: it self-cleans when the issue is resolved, so dead suppressions don't accumulate.

Practice Exercises

Three exercises that show up in some form in most senior interviews.

1. Type-safe pick

TypeScript
function pick<T extends object, K extends keyof T>(
  obj: T,
  keys: readonly K[],
): Pick<T, K> {
  const result = {} as Pick<T, K>;
  for (const key of keys) result[key] = obj[key];
  return result;
}

2. Type-safe Result

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

function unwrap<T, E>(r: Result<T, E>): T {
  if (r.ok) return r.value;
  throw r.error;
}

3. Type-safe event emitter

TypeScript
type EventMap = {
  "user.created": { id: string; name: string };
  "user.deleted": { id: string };
  "order.paid":   { orderId: string; amount: number };
};

class TypedEmitter<E extends Record<string, unknown>> {
  private listeners: { [K in keyof E]?: Array<(payload: E[K]) => void> } = {};

  on<K extends keyof E>(event: K, fn: (payload: E[K]) => void): void {
    (this.listeners[event] ??= []).push(fn);
  }

  emit<K extends keyof E>(event: K, payload: E[K]): void {
    this.listeners[event]?.forEach((fn) => fn(payload));
  }
}

const bus = new TypedEmitter<EventMap>();
bus.on("user.created", (p) => p.name); // p is fully typed
bus.emit("order.paid", { orderId: "1", amount: 99 });

Common Interview Questions With Strong Answers

1. What is TypeScript?

TypeScript is a statically typed superset of JavaScript that adds compile-time type checking. It improves tooling, refactoring safety, and acts as living documentation. It doesn't change runtime behavior. Types are erased during compilation.

2. Does TypeScript guarantee runtime safety?

No. Types are erased at compile time. External data like API responses, form input, env vars, and third-party SDKs still needs runtime validation, typically with libraries like Zod.

3. any vs unknown?

any disables type checking and is contagious. unknown requires narrowing before use. I default to unknown for untrusted data and only fall back to any when interoperating with poorly-typed third-party code, with a comment explaining why.

4. type vs interface?

Both describe object shapes. interface supports declaration merging, which makes it useful for public contracts and module augmentation (extending Express's Request, for example). type is more flexible: unions, intersections, mapped and conditional types, primitives. In application code I lean on type; in library code I prefer interface for extendable contracts.

5. What's a discriminated union and why use it?

A union where each member shares a common literal property used for narrowing. They let me model state machines so that invalid combinations are unrepresentable. The canonical example is replacing { loading, error?, data? } with separate idle, loading, success, and error variants.

6. What is never and when do you use it?

never represents impossible values. Most often I use it for exhaustive checks in switch statements over discriminated unions: assigning the discriminant to never in the default case turns "we forgot to handle a case" into a compile error.

7. What are generics for?

Preserving type relationships across boundaries. Instead of returning any and losing type information, generics let me say "the output's type depends on the input's type", as in pick<T, K extends keyof T> returning Pick<T, K>.

8. What's satisfies and when do you reach for it?

satisfies validates a value against a type without widening to that type. Useful for config objects, route maps, theme tokens, and anywhere I want to enforce a contract while keeping precise literal types for inference downstream.

9. How do you type API responses safely?

I treat the response as unknown, parse it through a runtime schema (Zod, Valibot, or ArkType), and derive the static type from the schema with z.infer. That way the type and the validation logic can't drift.

10. What's structural typing?

TypeScript compares types by shape, not by name. Two types with the same fields are compatible regardless of declaration. When I need nominal-style guarantees (for IDs, units, sanitized strings), I use branded types.

11. What's a distributive conditional type?

When a conditional type's checked type is a naked type parameter, it distributes over unions. So T extends U ? X : Y applied to A | B becomes (A extends U ? X : Y) | (B extends U ? X : Y). To prevent distribution, wrap in a tuple: [T] extends [U]. This is how NonNullable and Exclude work internally.

Pro Tips

  1. Let inference do the work locally; annotate at boundaries. Don't write const x: number = 1. Do annotate exported function signatures and public APIs.
  2. Prefer unknown to any, always. Treat any as a code smell that needs justification.
  3. Derive types from runtime schemas, not the reverse. z.infer<typeof Schema> keeps validation and types in sync.
  4. Make invalid states unrepresentable. Reach for discriminated unions before defensive checks.
  5. Avoid type assertions (as Foo) when possible. Use type guards or schema parsing instead. Each as is a small lie to the compiler.
  6. Don't over-engineer types. A four-line type is usually better than a clever one-liner. Library code is the place for type wizardry; application code is the place for clarity.
  7. Turn on noUncheckedIndexedAccess early. It catches a real class of bugs that nothing else does.
  8. Prefer @ts-expect-error over @ts-ignore. It self-cleans when the underlying issue is fixed.
  9. Know which utility types exist so you don't reinvent Pick, Omit, ReturnType, or Awaited.

Study Plan (Flexible: 3 to 10 Days)

Use whichever pacing fits your timeline. The phases are the same. Only the depth and time per phase changes. The goal is the same too: walk in confident, with concrete examples ready.

Phase 1: Foundations (the non-negotiables)

  • The mental model: types disappear at runtime
  • type vs interface (and module augmentation)
  • any / unknown / never
  • Union, intersection, narrowing
  • Discriminated unions (the most important pattern)
  • keyof, typeof, indexed access types

3-day pace: half a day. 10-day pace: day 1, with hands-on examples for each concept.

Phase 2: Generics & Utility Types

  • Generic functions, generic React components
  • Constraints (K extends keyof T)
  • All twelve core utility types
  • NoInfer, const type parameters

3-day pace: half a day. 10-day pace: days 2-3, including writing your own versions of Partial, Pick, Omit, and Record from scratch.

Phase 3: Advanced Type Mechanics

  • Mapped types and modifiers
  • Conditional types and infer
  • Distributive conditional types
  • Template literal types
  • as const and satisfies

3-day pace: day 2. 10-day pace: days 4-5, with one small exercise per topic.

Phase 4: Runtime Boundaries

  • Type guards, assertion functions, the filter predicate pattern
  • unknown in catch
  • Zod / Valibot / ArkType, z.infer<typeof Schema>
  • Designing the API/form/env/webhook validation layer

3-day pace: half a day. 10-day pace: days 6-7, with a small project where you replace as casts with schema parsing.

Phase 5: Senior Topics

  • Structural vs nominal typing, branded types
  • Variance (covariance / contravariance / bivariance gotcha)
  • Recursive types, type-only imports
  • enum vs union literals
  • Function overloads vs generics

3-day pace: half a day. 10-day pace: days 8-9.

Phase 6: Mock Interview & Project Stories

Walk through your real projects with these prompts in mind:

  • Where did TypeScript catch a bug for you?
  • Where did you use generics to preserve types across a layer?
  • Where did you add runtime validation? What library, and why?
  • Where did you reach for a discriminated union? What did it replace?
  • What strict flags do you turn on, and why?

Practice the Common Interview Questions answers above out loud, twice. Once dry, once with a concrete example from your work attached.

3-day pace: day 3. 10-day pace: day 10.

Final Thoughts

The senior-level signal in a TypeScript interview isn't reciting Pick<T, K>. It's showing that you understand why the type system exists, where it ends, and how to design types that prevent bugs instead of just describing data.

The mindset shift, in one sentence:

You're not annotating code. You're modeling a domain so that wrong code doesn't compile.

Bring that energy in, back it with concrete examples from your own work, and the rest is just remembering syntax. Go ace it 👊