Interview & Career • 14 min read • 10/09/2026

Senior Frontend Interview: Deep TypeScript — Generics, Conditional Types and the Type System in Practice

Bài này có bản tiếng Việt. Đọc bản tiếng Việt →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Typing code on a keyboard
Photo: Kindohm · CC BY-SA 4.0

At the Senior level, the TypeScript question is rarely “what’s the difference between interface and type”. I’ve sat in both chairs, and what they usually put in front of you is an API client, a big form, or an internal library full of any, plus one question: “how would you make this type-safe, and is that type worth maintaining?” They want to see you use the type system as a design tool. Catch bugs early. Get autocomplete that actually helps. But don’t turn the codebase into a maze nobody on the team can fix.

Every section follows the rhythm of a real interview: the question, an answer that works but isn’t deep, the senior-level answer, then a trap follow-up. The type code here has been run through tsc --strict, including the @ts-expect-error lines. Those errors really do show up.

1. Constrained generics: writing functions that are “exactly typed” instead of “roughly typed”

The interviewer asks: “Write pick(obj, keys) that returns an object with only the chosen keys. A wrong key should be an error, and the return type should be exact.”

The “works but not deep” answer: function pick(obj: any, keys: string[]): any. It runs, but all type information is gone. No autocomplete after the call. Mistype a key and nobody tells you.

The senior answer: two type parameters, where the first constrains the second.

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

const user = { id: 1, name: "An", role: "admin" as const };

const small = pick(user, ["id", "name"]); // { id: number; name: string }
// @ts-expect-error: "email" is not a key of user
pick(user, ["email"]);

K extends keyof T is where the value is. TypeScript infers K from the array literal ("id" | "name"), and any element outside keyof T is an error right at the call site. The constraint also unlocks operations inside the function: obj[k] is valid only because k belongs to keyof T.

A more realistic one is groupBy, where the type of the key decides the type of the result:

function groupBy<T, K extends PropertyKey>(
  items: readonly T[],
  key: (item: T) => K,
): Record<K, T[]> {
  const out = {} as Record<K, T[]>;
  for (const item of items) {
    const k = key(item);
    (out[k] ??= []).push(item);
  }
  return out;
}

const grouped = groupBy(
  [{ n: 1, t: "a" as const }, { n: 2, t: "b" as const }],
  (x) => x.t,
);
// grouped: Record<"a" | "b", { n: number; t: "a" | "b" }[]>

And a structural constraint, common in app-level helpers:

interface HasId {
  id: string;
}

function indexById<T extends HasId>(items: readonly T[]): Map<T["id"], T> {
  return new Map(items.map((i) => [i.id, i] as const));
}

Trap follow-up: “When are generics overkill?” My rule: a type parameter should appear at least twice, either input and output, or two related inputs. function log<T>(x: T): void uses T exactly once and carries no more information than x: unknown. Needless generics make the signature harder to read and the error messages longer, and you get nothing back.

2. Conditional types, infer, and the distribution trap on unions

The interviewer asks: “What’s a conditional type? What’s infer for? Give me an example you’ve written yourself.”

The “works but not deep” answer: “It’s T extends U ? X : Y, a ternary for types.” The syntax is right. It says nothing about behavior.

The senior answer: a conditional type picks a type based on assignability. infer declares a type variable inside the extends clause so you can capture the part of the type that matches a pattern:

type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;
type Deep = UnwrapPromise<Promise<Promise<number>>>; // number

type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type Tag = ElementOf<readonly ["a", "b"]>; // "a" | "b"

type ReturnTypeOf<F> = F extends (...args: any[]) => infer R ? R : never;
type R1 = ReturnTypeOf<() => number>; // number

The any[] in ReturnTypeOf is one of the few places any is justified. We need “any parameter list”, and it sits in a function’s parameter position (contravariant), so unknown[] would make many functions fail to match. The standard library writes ReturnType the same way.

The part that gets probed is distributive conditional types. When T is a “naked” type parameter and you pass in a union, the conditional type applies to each member of the union and then merges the results:

type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]

// Wrap in a tuple to turn off distribution
type ToArrayAll<T> = [T] extends [unknown] ? T[] : never;
type B = ToArrayAll<string | number>; // (string | number)[]

One consequence: never is the empty union, so ToArray<never> gives never, not never[]. A lot of confusing type bugs come from here. To compare the whole union at once, wrap it in [T].

Trap follow-up: “Is there a limit on recursive types like UnwrapPromise?” Yes. The compiler caps instantiation depth, with the error “Type instantiation is excessively deep and possibly infinite”, and overly complex types slow the editor down. Internal-library types can survive that, as long as they have tests (@ts-expect-error or a type-testing tool) and someone understands them. For business logic, keep the types simple and leave the rest to runtime validation.

3. Mapped types and template literal types: renaming keys with types

The interviewer asks: “How do you turn { id: number; name: string } into { getId: () => number; getName: () => string } using types?”

The “works but not deep” answer: “Write each one by hand”, or “use Partial, Pick”. They know how to use utility types but have never written a mapped type themselves.

The senior answer: a mapped type iterates over the keys of a type, and the as clause lets you rename them (key remapping). Combine that with template literal types and intrinsics like Capitalize:

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

type User = { id: number; name: string };
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string }

string & K is required: keyof T can include number and symbol, but Capitalize only accepts string. Modifiers belong to mapped types too, and -readonly and -? strip the corresponding property modifier:

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

Template literal types are strongest alongside infer. For example, extracting parameter names from a route like /users/:id/posts/:postId:

type ParamNames<P extends string> =
  P extends `${string}:${infer Name}/${infer Rest}`
    ? Name | ParamNames<`/${Rest}`>
    : P extends `${string}:${infer Name}`
      ? Name
      : never;

type RouteParams<P extends string> = { [K in ParamNames<P>]: string };

const ok: RouteParams<"/users/:id/posts/:postId"> = { id: "1", postId: "2" };
// @ts-expect-error: missing "id"
const bad: RouteParams<"/users/:id"> = {};

Read this type bottom-up. If the string has :name followed by /, take that name and recurse on the rest. If :name is at the end, take it and stop. Template literals also distribute over unions: `${T}Changed` turns "user" | "cart" into "userChanged" | "cartChanged".

Trap follow-up: “Would you use this to type-check entire URLs?” Careful. Template literals catch typos in route names and params, but they don’t replace runtime validation, because data coming in from the server is unknown. I use it on the call-site, not on the receiving side.

4. Narrowing and discriminated unions: each state carries only its own data

The interviewer asks: “Model the state of a request: idle, loading, success, error, so that you can’t access data before it succeeds.”

The “works but not deep” answer: one interface with loading: boolean; data?: T; error?: Error. That allows impossible states, like loading: true with an error attached. And data can always be undefined, so the code fills up with ? and !.

The senior answer: a discriminated union, with a literal field as the discriminant. Each state holds only the data that’s valid for it:

type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

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

function render(s: FetchState<string[]>): string {
  switch (s.status) {
    case "idle":
    case "loading":
      return "...";
    case "success":
      return s.data.join(", "); // s is narrowed to the success branch
    case "error":
      return s.error.message;
    default:
      return assertNever(s); // add a new state and forget to handle it -> compile error
  }
}

assertNever gives you exhaustiveness checking. If someone adds { status: "stale" } to the union and forgets to update the switch, s in the default branch is no longer never, and the compiler complains immediately. Tests rarely catch this kind of bug.

The four branches of the union, and what each one hands you:

flowchart TD
  U["FetchState: union of 4 states"] --> I["idle — carries no data"]
  U --> L["loading — carries no data"]
  U --> S["success — carries data: T"]
  U --> E["error — carries error: Error"]
  S --> SP["switch into success branch: s.data is usable"]
  E --> EP["switch into error branch: s.error.message"]
  U --> EX["add a new status and forget to handle it"] --> DF["default falls into assertNever(s): compile error"]

Besides switch, here are other narrowing techniques interviewers often ask about:

// Type predicate: a user-defined guard
function isDefined<T>(v: T | null | undefined): v is T {
  return v != null;
}
const defined: number[] = [1, null, 2].filter(isDefined);

// Assertion function: throws on failure, narrows the code after it
function assertString(v: unknown): asserts v is string {
  if (typeof v !== "string") throw new TypeError("not a string");
}

// Guard for data of unknown type
function isUser(x: unknown): x is { id: number; name: string } {
  return (
    typeof x === "object" &&
    x !== null &&
    "id" in x &&
    typeof x.id === "number" &&
    "name" in x &&
    typeof x.name === "string"
  );
}

Trap follow-up: “Are type predicates safe?” Not entirely: the compiler trusts you. If isUser is written wrong (say it forgets to check name), it still returns x is ... and the wrong type spreads everywhere. So for data at the system boundary (API, localStorage, postMessage), I prefer a schema library (Zod, Valibot or similar), so one declaration both validates at runtime and generates the type, instead of hand-writing a guard for every type.

5. satisfies: checking without “flattening” the type

The interviewer asks: “What’s the difference between const x: T = ..., ... as T and ... satisfies T?”

The “works but not deep” answer: “satisfies is the new way to type-check.” Where the benefit is, they have no idea.

The senior answer: three forms, three different needs.

  • An annotation : T replaces the inferred type with T; the specific information is lost.
  • An assertion as T turns off checking (the two types only need to be close enough), so it’s easy to hide errors.
  • satisfies T checks that the value matches T but keeps the narrowest inferred type.
const routes = {
  home: "/",
  user: "/users/:id",
} satisfies Record<string, string>;

type RouteName = keyof typeof routes; // "home" | "user"  (not string)

const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
} satisfies Record<string, string | number[]>;

palette.red.map((n) => n * 2);   // OK: red is still number[]
palette.green.toUpperCase();     // OK: green is still string

const config = { env: "prod", retries: 3 } as const satisfies {
  env: "dev" | "prod";
  retries: number;
};
// config.env has type "prod", not string

Switch to the annotation Record<string, string | number[]> in the palette example and palette.red.map errors out right away, because red is widened to string | number[]. Use as and a typo in the config slips through quietly. satisfies blocks both. The as const satisfies combo is the pattern I use most for config tables, route maps and registries.

Trap follow-up: “Is there anywhere satisfies doesn’t help?” Yes. It doesn’t change the type of the variable, so when you need a declared type as a public contract, such as the return type of an exported function, an annotation is still the better fit. satisfies also doesn’t check runtime data.

6. Type-safe API client: one schema table, everything else inferred

A synthesis question you see a lot in senior rounds: “Design a request function so that the endpoint name, path params, body and return type are all checked.”

The “works but not deep” answer: request<T>(url: string, body?: unknown): Promise<T>. The caller declares T themselves. A promise the caller makes to the compiler, with nothing stopping them from declaring it wrong.

The senior answer: put the API contract into one schema table, then infer everything from it.

interface User { id: number; name: string }
interface CreateUser { name: string }

interface ApiSchema {
  "GET /users": { response: User[] };
  "GET /users/:id": { response: User };
  "POST /users": { body: CreateUser; response: User };
  "PATCH /users/:id": { body: Partial<CreateUser>; response: User };
}

type PathOf<K extends string> = K extends `${string} ${infer P}` ? P : never;

type ParamsOpt<K extends string> = [ParamNames<PathOf<K>>] extends [never]
  ? unknown
  : { params: Record<ParamNames<PathOf<K>>, string> };

type BodyOpt<K extends keyof ApiSchema> = ApiSchema[K] extends {
  body: infer B;
}
  ? { body: B }
  : unknown;

type RequestOptions<K extends keyof ApiSchema> = ParamsOpt<K & string> &
  BodyOpt<K>;

// A route with no params/body doesn't need a second argument
type RequestArgs<K extends keyof ApiSchema> =
  keyof RequestOptions<K> extends never ? [] : [options: RequestOptions<K>];

async function request<K extends keyof ApiSchema & string>(
  route: K,
  ...args: RequestArgs<K>
): Promise<ApiSchema[K]["response"]> {
  const [method, template] = route.split(" ") as [string, string];
  const options = (
    args as unknown as [{ params?: Record<string, string>; body?: unknown }?]
  )[0];

  const path = template.replace(/:(\w+)/g, (_, name: string) =>
    encodeURIComponent(options?.params?.[name] ?? ""),
  );

  const res = await fetch(path, {
    method,
    headers: { "Content-Type": "application/json" },
    body: options?.body === undefined ? undefined : JSON.stringify(options.body),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as ApiSchema[K]["response"];
}

(ParamNames is the type from section 3.) On the caller’s side it looks like this:

async function demo() {
  const list = await request("GET /users");                       // User[]
  const one = await request("GET /users/:id", { params: { id: "1" } }); // User
  const made = await request("POST /users", { body: { name: "An" } });  // User

  // @ts-expect-error: missing params.id
  await request("GET /users/:id");
  // @ts-expect-error: wrong field name in body
  await request("POST /users", { body: { nme: "x" } });
  // @ts-expect-error: route doesn't exist in the schema
  await request("DELETE /users");
  return [list, one, made];
}

The types above chain together, going from the route name to the return type:

flowchart TD
  K["keyof ApiSchema: the four routes in the table"] --> P["PathOf: split out the path after the space"]
  P --> PN["ParamNames: ':id' becomes 'id'"]
  PN --> PO["ParamsOpt: a route with params must pass params"]
  K --> BO["BodyOpt: a schema with a body branch must pass body"]
  PO --> RO["RequestOptions: combine ParamsOpt with BodyOpt"]
  BO --> RO
  RO --> RA["RequestArgs: no options means no second argument needed"]
  RA --> RESP["Return type: the response branch of ApiSchema[K]"]

I have to be upfront about the “dirty” part of this solution. Inside request I cast twice (as unknown as, as ApiSchema[K]["response"]). The compiler can’t prove the function body matches the conditional type in the signature. In exchange, the casts are locked into one place and the public API surface stays strict. And the final as ... ["response"] is just a promise about the server, not something TypeScript verifies. If the server returns something different from the schema, it blows up at runtime. To be sure, generate the schema from OpenAPI, or validate the response with a schema library right at the boundary.

Trap follow-up: “Why not use overloads for each endpoint?” With a few endpoints, overloads are fine, but they’re hand-written and drift from reality easily. The schema table is the single source of truth, and adding an endpoint is adding one line. The trade-off: complicated inferred types have a price, and error messages get harder to read. A thin layer of named functions (getUser(id)) wrapped around request is enough to make errors readable again.

7. Type-safe forms: nested paths

The interviewer asks: “A form has nested data (profile.address.city). How do you make getValue(form, 'profile.address.city') return the right type, and give an error on a mistyped path?”

The senior answer: two types. One generates the set of all valid paths. The other looks up the value type by path.

type Path<T> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? K | `${K}.${Path<T[K]>}`
        : K;
    }[keyof T & string]
  : never;

type PathValue<T, P extends string> = P extends `${infer Head}.${infer Rest}`
  ? Head extends keyof T
    ? PathValue<T[Head], Rest>
    : never
  : P extends keyof T
    ? T[P]
    : never;

function getValue<T, P extends Path<T>>(obj: T, path: P): PathValue<T, P> {
  let cur: unknown = obj;
  for (const key of path.split(".")) {
    cur = (cur as Record<string, unknown>)[key];
  }
  return cur as PathValue<T, P>;
}

interface SignupForm {
  email: string;
  profile: { age: number; address: { city: string } };
}
declare const form: SignupForm;

const city: string = getValue(form, "profile.address.city");
const age: number = getValue(form, "profile.age");
// @ts-expect-error: "agee" doesn't exist
getValue(form, "profile.agee");

Path<SignupForm> is the union "email" | "profile" | "profile.age" | "profile.address" | "profile.address.city", so the editor suggests as you type. Modern form libraries type functions like register("profile.age") or watch with the same mechanism.

Limits of this version: it doesn’t handle arrays (items.0.name), and it doesn’t account for Date or other special objects, which also extends object and so get “opened up” property by property. Real libraries handle these. If you’re not building a library, reuse the form library’s types instead of writing your own.

Trap follow-up: “Does this type affect compile performance?” Yes. A recursive Path over a large object produces a union with a huge number of members, and the editor slows down noticeably. How to deal with it: measure with tsc --extendedDiagnostics, cap the recursion depth, or accept a looser type (string) for gigantic forms. Type safety has a budget, and a senior knows when to stop.

8. Avoiding any without giving up pragmatism

The interviewer asks: “The codebase has hundreds of any. What do you do?”

The “works but not deep” answer: “Replace any with unknown”, or “turn on strict and we’re done”. Right direction. But no strategy.

The senior answer: sort them into groups first. Each kind of any needs a different treatment:

  1. any from the system boundary: JSON.parse, fetch().json(), localStorage. This is the source of most wrong types. Fix: have the function return unknown, then validate (schema or guard) before narrowing.
  2. any from laziness: undeclared props, callbacks like (e: any). Fix: declare the right type; use the no-explicit-any lint rule at warning level and raise it to an error directory by directory.
  3. any with a reason: the boundary with an untyped library, contravariant parameter positions like in section 2. Fix: isolate it in one wrapper file, add a comment explaining why, and type the public side.

Those three groups, in one picture:

flowchart TD
  A["any in the codebase"] --> B["any at the system boundary"]
  A --> C["any from laziness"]
  A --> D["any with a reason"]
  B --> B1["JSON.parse, fetch().json(), localStorage"]
  B1 --> B2["return unknown, then validate with a schema or guard"]
  C --> C1["undeclared props, callback (e: any)"]
  C1 --> C2["declare the right type, tighten the no-explicit-any lint directory by directory"]
  D --> D1["untyped library, contravariant parameter"]
  D1 --> D2["isolate in one wrapper file, add a reason comment, type the public side"]
function parseJson(text: string): unknown {
  return JSON.parse(text);
}

const data = parseJson("{}");
// data.name  // error: 'data' is of type 'unknown'
if (isUser(data)) {
  data.name.toUpperCase(); // safe after narrowing
}

// Object.keys returns string[] because an object can have extra keys at runtime.
// Deliberate cast, when you know the object isn't extended:
const keys = Object.keys(form) as (keyof SignupForm)[];

// Branded type: avoid mixing up ids that are all strings
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;

declare function loadUser(id: UserId): void;
// @ts-expect-error: a plain string is not a UserId
loadUser("abc");
loadUser("abc" as UserId); // centralize the cast where the id is created (e.g. a parse function)

Two config flags I always bring up with the team: strict: true (which includes strictNullChecks, noImplicitAny…) and noUncheckedIndexedAccess, which makes arr[i] and record[key] have type T | undefined. Turning on the second costs effort to fix things at first, and in return it blocks a whole class of “read an element that doesn’t exist” bugs.

Trap follow-up: “What’s the difference between unknown and any?” Both accept any value. But any turns off checking, so you can do anything with it. unknown forces you to narrow before use. A bonus point: any is contagious. An any value passing through a function makes the return type any too, so one loose spot disables checking for the whole chain after it.

What interviewers look for in a Senior

Having sat in both chairs, these are the signals I notice:

  • Knowing what types are for. Strong candidates start from the problem (“which bug do I want to block?”) and only then pick a type tool. Weak candidates show off complicated types without explaining what errors they prevent.
  • Knowing the limits. Types only exist at compile time. Anyone who claims to be “fully type-safe” when the data comes over the network hasn’t understood the boundary between compile time and runtime.
  • Knowing the cost. Recursive types, huge unions, error messages half a screen long are all maintenance debt. A senior weighs it: can teammates fix this, and are there tests for the types?
  • Isolating the mess. A cast isn’t a sin if it sits in one deliberate place, with a comment, behind a strict public surface.
  • Showing a process. Describe how you’d bring type safety into an old codebase step by step: turn on strict per directory, tighten lint gradually, handle system boundaries first.

Review checklist

  • Rewrite from scratch, without looking: Pick, Omit, ReturnType, Awaited, Parameters, to understand they’re built from mapped types and infer.
  • Build a generic function with two related type parameters (like pick, groupBy) and explain each constraint.
  • Model a business flow with a discriminated union and assertNever, then try adding a new state and watch the compiler stop you.
  • Write a small type-safe request for your own API, noting where you had to cast and why.
  • Prepare a real story: which type once saved you from a bug, and which type you had to simplify because it was too hard to maintain. The second story usually impresses more.

In the interview room, the longest conditional type is not a plus. Three questions I ask myself when reviewing my own types: what error does it block, can a teammate read it, and where does the compiler not guarantee anything.

Need a developer for a contract or freelance project?

I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.