Senior Frontend Depth

Advanced TypeScript for Interviews: Generics, Narrowing, Utility and Conditional Types

Generic constraints, discriminated unions, infer, mapped types, satisfies, and keeping API response types correct from the server to the UI.

Lesson 0420 min read#typescript#generics#types

On this page

  1. Key terms first
  2. unknown vs any
  3. Generics
  4. Narrowing
  5. Utility types
  6. Mapped types
  7. Conditional types and infer
  8. Implementing DeepPartial<T>
  9. as const and satisfies
  10. type vs interface
  11. Branded (nominal-ish) types
  12. Keeping API types in sync
  13. Overloads, tuples and other tools worth recognising
  14. Common mistakes
  15. Interview questions
  16. Key takeaways
  17. What's next

In one line: advanced TypeScript is about making impossible states impossible to write, and letting types flow from one source of truth. Learn generics with constraints, narrowing (especially discriminated unions), mapped and conditional types, and one habit that fixes most API bugs: get types from schemas instead of writing them twice.

Interviews rarely ask for type tricks for their own sake. They ask three things. Can you type a reusable function correctly? Can you model a state machine safely? Can you keep frontend types in sync with a backend you do not fully control?

Key terms first

  • TypeScript is a language that adds types to JavaScript. A type is a label that says what kind of value something is (a number, a string, an object with certain fields). A type checker reads your code before it runs and reports places where the types do not match.
  • Type annotation means writing a type yourself, such as const n: number = 1. Type inference means TypeScript works out the type for you.
  • A union type means "one of these types", written A | B. An intersection type means "all of these types at once", written A & B.
  • A literal type is a type that allows only one exact value, such as "circle" or 42.
  • A type assertion (value as Foo) is you telling the compiler "trust me, this is a Foo". The compiler does not check it.
  • strict is a tsconfig setting that turns on the strictest group of checks. noUncheckedIndexedAccess is an extra setting that makes array[i] have the type T | undefined, because the index might be out of range.
  • Type erasure means that types exist only while you write and compile. They are removed from the JavaScript that runs.
  • Versions: every snippet in this lesson was type-checked with the TypeScript installed in this repo (7.0.2, strict on). Most features are old: unknown (3.0), conditional types and infer (2.8), assertion functions (3.7). Newer ones: key remapping with as and template literal types (4.1), noUncheckedIndexedAccess (4.1), Awaited (4.5), satisfies and in narrowing for unlisted properties (4.9).

unknown vs any

One line of difference: any means "skip the checks", and unknown means "I must check before I use it".

  • any is a special type that turns off type checking for that value. It spreads quietly: a value made from an any is also any.
  • unknown is a special type that means "a value I know nothing about yet". It is called a top type, because every value fits into it. You cannot use it until you narrow it (check what it is). It is the safe version of any. Use it for data from outside your program (JSON.parse, fetch().json(), catch (e)).
JavaScript
{  function parse(json: string): unknown { return JSON.parse(json); }  const v = parse('{"id": 1}');  // v.id            // ❌ error: 'v' is of type 'unknown'  if (typeof v === "object" && v !== null && "id" in v) {    console.log(v.id);   // ✅ after narrowing, v is { id: unknown } (needs TypeScript 4.9 or newer)  }}

Use unknown at the edges of your program (the places where outside data comes in), and check the data with a schema (below). A schema is a description of what valid data looks like, written as code that can check data while the program runs. Turn on strict (and noUncheckedIndexedAccess) in tsconfig, the file that holds TypeScript settings.

Generics

A generic is a type parameter: a placeholder type, written in angle brackets like <T>, that lets one function or type work for many types while keeping the link between them. Everyday comparison: a labelled box where you decide later what goes inside, and the label always tells you what you will get out. The T | undefined below means "a T, or nothing".

JavaScript
{  // The return type depends on the input type: no information is lost  function first<T>(items: readonly T[]): T | undefined { return items[0]; }  const n = first([1, 2, 3]);        // number | undefined  const s = first(["a", "b"]);       // string | undefined}

Constraints: extends

A constraint is a rule, written with extends, that says what the type parameter must at least look like. Here T can be any type, as long as it has a string id:

JavaScript
{  function byId<T extends { id: string }>(items: T[]): Map<string, T> {    return new Map(items.map((i) => [i.id, i]));  }  byId([{ id: "a", name: "Ada" }]);      // ✅ T inferred as { id: string; name: string }  // byId([{ name: "x" }]);              // ❌ error: the object has no 'id'}

keyof and indexed access

keyof T is a type operator that gives a union of the key names of T. T[K] is an indexed access type: it gives the type of the property K of T. Example: for { id: number; name: string }, keyof gives "id" | "name".

JavaScript
{  function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {   // return type is the exact property type    return obj[key];  }  const user = { id: 1, name: "Ada" };  const name = pluck(user, "name");        // string  // pluck(user, "age");                    // ❌ "age" is not a key of the object}

Defaults and inference

JavaScript
{  type ApiResult<T = unknown, E = Error> = { ok: true; data: T } | { ok: false; error: E };  const r: ApiResult<number> = { ok: true, data: 1 };}

A type argument is the real type you put in for a placeholder (ApiResult<number>). A default (T = unknown) is used when you give none. Let TypeScript infer (work out) type arguments from how you use the function. Write them yourself only when it cannot.

Narrowing

Narrowing means TypeScript makes a type more exact inside a branch (an if or a case), based on checks it understands. Example: after typeof x === "string", x is a string inside the if.

TechniqueExample
typeoftypeof x === "string"
instanceoferr instanceof Error
in"radius" in shape
Truthiness / equalityif (x), x === null, x !== undefined
Discriminant propertyshape.kind === "circle"
User-defined type guard (a function that returns true or false and tells TypeScript the type)function isUser(x: unknown): x is User
Assertion function (a function that throws if the check fails, so the code after it can rely on the type)function assertDefined<T>(x: T): asserts x is NonNullable<T>

Discriminated unions

In a discriminated union, every variant has a tag with a literal value (the discriminant). When you check the tag, TypeScript lets you use the fields of that variant.

JavaScript
{  type Shape =    | { kind: "circle"; radius: number }    | { kind: "rect"; width: number; height: number }    | { kind: "square"; size: number };  function area(s: Shape): number {    switch (s.kind) {      case "circle": return Math.PI * s.radius ** 2;      case "rect": return s.width * s.height;      case "square": return s.size ** 2;      default: {        const _exhaustive: never = s;          // compile error if a new variant is added and not handled        return _exhaustive;      }    }  }}

Exhaustive checks mean the compiler proves you handled every case. You assign the leftover value to never (the type with no possible values). If someone adds a new variant ("triangle") and forgets to handle it, the compiler fails. This is how discriminated unions "enable exhaustive checks".

This is also the right way to model async state. It replaces several booleans (Lesson 03):

JavaScript
{  type Remote<T> =    | { status: "idle" }    | { status: "loading" }    | { status: "success"; data: T }    | { status: "error"; error: Error };  function label(r: Remote<string[]>): string {    switch (r.status) {      case "idle": return "Not started";      case "loading": return "Loading…";      case "success": return `${r.data.length} items`;   // data exists only here      case "error": return r.error.message;              // error exists only here    }  }}

Type guards

A type guard is a function whose return type is x is User. It tells TypeScript: "if I return true, the value is a User".

JavaScript
{  type User = { id: string; name: string };  function isUser(x: unknown): x is User {    return typeof x === "object" && x !== null      && typeof (x as Record<string, unknown>).id === "string"      && typeof (x as Record<string, unknown>).name === "string";  }  function handle(input: unknown) {    if (isUser(input)) console.log(input.name.toUpperCase());   // narrowed to User  }}

Be careful. A type guard is a promise you make. If the guard is wrong, the compiler still believes it. For real data, prefer schema validation.

Utility types

A utility type is a ready-made generic type that builds a new type from another one. They are built into TypeScript.

UtilityWhat it does
Partial<T> / Required<T>Makes all properties optional / required
Readonly<T>Makes all properties read-only
Pick<T, K> / Omit<T, K>Keeps / removes some keys
Record<K, V>An object type with keys K and values V
Exclude<U, X> / Extract<U, X>Removes / keeps the union members that fit X
NonNullable<T>Removes null and undefined
ReturnType<F> / Parameters<F>Gives a function's return type / parameter types
Awaited<T>Unwraps a Promise type (gives the type you get after await)
JavaScript
{  type User = { id: string; name: string; email: string; passwordHash: string };  type PublicUser = Omit<User, "passwordHash">;                    // never leak the hash in a response type  type UserPatch = Partial<Pick<User, "name" | "email">>;          // PATCH body  type ById = Record<User["id"], User>;}

Mapped types

A mapped type is a type that builds a new object type by going through each key of another type and changing it. You write it as { [K in keyof T]: … }. A template literal type builds a string type from parts, like `get${string}`. Key remapping (as) renames the keys:

JavaScript
{  type Mutable<T> = { -readonly [K in keyof T]: T[K] };            // remove readonly  type Nullable<T> = { [K in keyof T]: T[K] | null };  type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };   // key remapping + template literal types  type Person = { name: string; age: number };  type PersonGetters = Getters<Person>;     // { getName: () => string; getAge: () => number }}

Conditional types and infer

A conditional type, written T extends U ? X : Y, is a type that picks X if T fits U, and Y if not. It is like an if for types. infer is a keyword that grabs a part of a type inside that test and gives it a name.

JavaScript
{  type IsString<T> = T extends string ? true : false;  type A = IsString<"x">;   // true  type B = IsString<1>;     // false  // Extract the element type of an array, and unwrap a Promise  type ElementOf<T> = T extends readonly (infer U)[] ? U : never;  type Unwrap<T> = T extends Promise<infer U> ? U : T;  type E = ElementOf<string[]>;          // string  type P = Unwrap<Promise<number>>;      // number  // A conditional type "distributes" over a union: it runs once for each member and joins the results  type ToArray<T> = T extends unknown ? T[] : never;  type X = ToArray<string | number>;     // string[] | number[]}

(The built-in ReturnType, Parameters and Awaited are written with infer.)

Implementing DeepPartial<T>

This is a classic interview question. The task: make every property optional, at every depth.

JavaScript
{  type DeepPartial<T> =    T extends (...args: never[]) => unknown ? T                       // leave functions alone    : T extends readonly (infer U)[] ? DeepPartial<U>[]               // arrays: partial elements    : T extends object ? { [K in keyof T]?: DeepPartial<T[K]> }       // objects: recurse    : T;                                                              // primitives: unchanged  type Settings = { theme: { dark: boolean; accent: string }; tags: string[]; save: () => void };  const patch: DeepPartial<Settings> = { theme: { dark: true } };     // ✅ nested partial  // const bad: DeepPartial<Settings> = { theme: { dark: "yes" } };  // ❌ error: string is not boolean (uncomment to see)}

Explain the parts in your answer. There is one conditional type for each kind of value. Objects and arrays use recursion (the type refers to itself, like a function that calls itself). Primitives and functions are the base case, where the recursion stops.

as const and satisfies

as const is a suffix that tells TypeScript to treat a value as fixed: literal types, and readonly. satisfies is an operator that checks a value against a type without changing the value's own type. Widening means a type becoming broader than the exact value (for example "admin" becoming string).

JavaScript
{  // as const: literal types and readonly  const ROLES = ["admin", "editor", "viewer"] as const;  type Role = (typeof ROLES)[number];              // "admin" | "editor" | "viewer"  // satisfies: check against a type WITHOUT widening the inferred type  type Route = { path: string; auth: boolean };  const routes = {    home: { path: "/", auth: false },    admin: { path: "/admin", auth: true },  } satisfies Record<string, Route>;  routes.home.path;                                 // type is string, and the keys "home" | "admin" are preserved  // a typo like { pth: "/" } would be an error, and routes keeps its precise shape}

satisfies checks a value against a contract, and it keeps the most exact type that TypeScript worked out. A type annotation (const routes: Record<string, Route> = …) would lose the known keys.

type vs interface

A type alias (type) gives a name to any type. An interface gives a name to an object shape. Both describe the shape of an object. The differences are small:

interfacetype
Extendingextends; declaration merging (two declarations with the same name join into one)Intersection &
Can describeObject shapes, class contractsObjects and unions, tuples, primitives, mapped/conditional types
Conflictsextends reports an error if the two shapes clash& does not report it; the clashing property can quietly become never
ConventionPublic library object typesUnions, utilities, anything computed

A common rule: use interface for object-like public APIs that others may extend, and type for everything else. Being consistent in one codebase matters more than which one you pick.

Branded (nominal-ish) types

A brand is a fake extra property added to a type, only at compile time, so that two types with the same shape count as different. It stops you from mixing up values that share the same primitive type (for example two IDs that are both strings). TypeScript normally uses structural typing (types match when their shapes match). A brand gives you something like nominal typing (types match only when their names match):

JavaScript
{  type Brand<T, B extends string> = T & { readonly __brand: B };  type UserId = Brand<string, "UserId">;  type OrderId = Brand<string, "OrderId">;  const asUserId = (s: string) => s as UserId;  function loadUser(id: UserId) { /* … */ }  const oid = "o_1" as OrderId;  // loadUser(oid);                // ❌ OrderId is not assignable to UserId  loadUser(asUserId("u_1"));        // ✅}

The brand exists only at compile time, so it costs nothing at runtime. Create branded values in one place, and check them there.

Keeping API types in sync

Scenario: frontend API types drift out of sync with the backend. The backend renames a field. The frontend's hand-written interface still has the old name. TypeScript is happy, and production breaks. Types you write by hand are guesses, not facts. Here are the options, strongest first:

  1. One schema, many users. Define a runtime schema (Zod, Valibot and ArkType are libraries for this) in a shared package. Get the types from it. Validate responses at the edge of your program.
JavaScript
{  // With Zod: const OrderSchema = z.object({ id: z.string(), totalMinor: z.number().int(), status: z.enum(["pending", "paid"]) });  type Parser<T> = { parse(input: unknown): T };  type OrderShape = { id: string; totalMinor: number; status: "pending" | "paid" };  const OrderSchema: Parser<OrderShape> = { parse: (input) => input as OrderShape };   // stand-in: a real schema checks every field  type Order = ReturnType<typeof OrderSchema.parse>;     // with Zod you would write z.infer<typeof OrderSchema>  async function getOrder(id: string): Promise<Order> {    const res = await fetch(`/api/orders/${id}`);    return OrderSchema.parse(await res.json());          // unknown → validated Order; throws if the API drifted  }}
  1. Generate types from an OpenAPI or GraphQL schema in CI (continuous integration, the automatic checks that run on a server). OpenAPI is a standard file format that describes a REST API. GraphQL is a query language for APIs that has its own typed schema. openapi-typescript and GraphQL Code Generator are tools that turn those descriptions into TypeScript types. A breaking change then becomes a compile error. Also add a breaking-change check on the spec (Backend, Lesson 11).
  2. End-to-end typed RPC. RPC (remote procedure call) means calling a function on the server as if it were local. tRPC is a library that does this with shared types. Typed server actions in a monorepo (one repository that holds several projects) work the same way. The client imports the server's types directly.
  3. Contract tests as a safety net. A contract test checks that the server really sends the shape the client expects.

Whichever you choose: never trust res.json() as a typed value. It returns any (or unknown if you type it that way). Validate at the edge so that your types describe what really happens.

Overloads, tuples and other tools worth recognising

An overload is a function with several written signatures (ways to call it), each with its own return type. A tuple is an array with a fixed length where each position has its own type. Both exist only at compile time.

JavaScript
{  // Function overloads: precise return types per call shape  function parseId(id: string): string;  function parseId(id: number): number;  function parseId(id: string | number) { return id; }  // Tuples with labels: fixed-length arrays with positions  type Range = [start: number, end: number];  // Generic components/hooks: relate props to a value  function useList<T extends { id: string }>(items: T[]) { return new Map(items.map((i) => [i.id, i] as const)); }}

You should also be able to explain these ideas:

  • Structural typing: two types are compatible when their shapes match, not when their names match.
  • Variance: the rules for when a type holding a Dog can be used where a type holding an Animal is expected. TypeScript allows Array<Dog> where Array<Animal> is expected, even though it is unsafe if someone then adds a Cat to the array.
  • readonly is a modifier that stops a property or array from being changed. never is the type with no possible values. It is used for code that cannot be reached.
  • Types are erased at runtime. They cannot check real data.

Common mistakes

  1. Using any as a shortcut. You lose all safety after that point.
  2. Using type assertions (as Foo) to silence the compiler instead of validating.
  3. Trusting fetch().json() as typed API data.
  4. Using several booleans instead of a discriminated union.
  5. Skipping the never exhaustiveness check in switches over unions.
  6. Hand-written API interfaces that drift.
  7. Over-engineering types that nobody can read. Prefer simple types with clear names.
  8. Forgetting that TypeScript types disappear at runtime.

Interview questions

unknown vs any?

any turns off type checking and infects everything it touches. unknown is the safe top type: it can hold any value, but you must narrow it (with typeof, instanceof, type guards or schema validation) before you use it. Prefer unknown for outside data and for catch variables.

Implement DeepPartial<T>.

Use a recursive conditional type. Leave functions as they are. Turn arrays into arrays of DeepPartial elements. Turn objects into { [K in keyof T]?: DeepPartial<T[K]> }. Return primitives unchanged.

How do discriminated unions enable exhaustive checks?

Each variant has a property with a literal value (the discriminant). When you check it, you can use the right fields. In a switch on the discriminant, the default branch can assign the remaining value to never. If someone adds a new variant and does not handle it, that assignment fails to compile, so you are forced to update the code.

type vs interface?

Both describe object shapes. Interfaces support declaration merging and read well for object contracts that others may extend. Type aliases can also describe unions, tuples, primitives, and mapped or conditional types. Pick a convention (for example interface for public object shapes, type for the rest) and stay consistent.

Scenario: frontend API types drift out of sync with the backend. How do you prevent it?

Get your types from one source of truth. That can be shared runtime schemas (for example Zod) used by both sides. It can be types and clients generated from OpenAPI or GraphQL schemas in CI. Or it can be end-to-end typed RPC. Validate responses at the edge instead of asserting types. Add breaking-change checks and contract tests, so drift fails the build.

What is the difference between satisfies and a type annotation?

An annotation widens the value to the declared type, so you lose specific keys or literal values. satisfies checks that the value fits the type, but it keeps the more exact type that TypeScript worked out. You get validation without losing information.

What does infer do?

Inside a conditional type, it declares a type variable that TypeScript fills in from the matched position. This lets you pull out part of a type: an array's element, a Promise's resolved value, or a function's return type.

How do generics with constraints help in a reusable function?

They let a function accept many types, while guaranteeing some properties and keeping the exact input type in the output. For example, function byId<T extends { id: string }>(items: T[]) can use id safely and still return the original element type, not a vague base type.

Scenario: any keeps creeping into the codebase. How do you stop it?

Make it costly and easy to see. Turn on strict and the lint rule against explicit any. Ban unchecked as casts at API edges. Validate outside data with schemas so types are real. Prefer unknown plus narrowing. Add a ratchet in CI (a check that only lets the count of any go down). A lint rule is a check by a tool (ESLint) that reads your code and warns about likely mistakes. Also ask why people reach for any. Often a generic or a schema is missing.

Key takeaways

  • Use unknown at the edges. Narrow it or validate it. Avoid any and unchecked as.
  • Generics with extends and keyof keep types connected. Learn mapped types and conditional types with infer.
  • Discriminated unions and never checks make impossible states impossible and updates safe.
  • as const and satisfies keep exact types while checking them.
  • Types are erased. Keep API types honest with shared schemas, generated types or typed RPC, and validate at runtime.

What's next

Lesson 05: Frontend Testing Strategy. Types catch one kind of bug. Tests catch bugs in behaviour. You will learn what to test, what to mock and what to skip.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series