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.
On this page
- Key terms first
- unknown vs any
- Generics
- Narrowing
- Utility types
- Mapped types
- Conditional types and infer
- Implementing DeepPartial<T>
- as const and satisfies
- type vs interface
- Branded (nominal-ish) types
- Keeping API types in sync
- Overloads, tuples and other tools worth recognising
- Common mistakes
- Interview questions
- Key takeaways
- 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", writtenA & B. - A literal type is a type that allows only one exact value, such as
"circle"or42. - A type assertion (
value as Foo) is you telling the compiler "trust me, this is a Foo". The compiler does not check it. strictis atsconfigsetting that turns on the strictest group of checks.noUncheckedIndexedAccessis an extra setting that makesarray[i]have the typeT | 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,
stricton). Most features are old:unknown(3.0), conditional types andinfer(2.8), assertion functions (3.7). Newer ones: key remapping withasand template literal types (4.1),noUncheckedIndexedAccess(4.1),Awaited(4.5),satisfiesandinnarrowing 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".
anyis a special type that turns off type checking for that value. It spreads quietly: a value made from ananyis alsoany.unknownis 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 ofany. Use it for data from outside your program (JSON.parse,fetch().json(),catch (e)).
{ 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".
{ // 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:
{ 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".
{ 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
{ 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.
| Technique | Example |
|---|---|
typeof | typeof x === "string" |
instanceof | err instanceof Error |
in | "radius" in shape |
| Truthiness / equality | if (x), x === null, x !== undefined |
| Discriminant property | shape.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.
{ 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):
{ 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".
{ 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.
| Utility | What 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) |
{ 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:
{ 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.
{ 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.
{ 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).
{ // 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:
interface | type | |
|---|---|---|
| Extending | extends; declaration merging (two declarations with the same name join into one) | Intersection & |
| Can describe | Object shapes, class contracts | Objects and unions, tuples, primitives, mapped/conditional types |
| Conflicts | extends reports an error if the two shapes clash | & does not report it; the clashing property can quietly become never |
| Convention | Public library object types | Unions, 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):
{ 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:
- 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.
{ // 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 }}- 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-typescriptand 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). - 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.
- 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.
{ // 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
Dogcan be used where a type holding anAnimalis expected. TypeScript allowsArray<Dog>whereArray<Animal>is expected, even though it is unsafe if someone then adds aCatto the array. readonlyis a modifier that stops a property or array from being changed.neveris 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
- Using
anyas a shortcut. You lose all safety after that point. - Using type assertions (
as Foo) to silence the compiler instead of validating. - Trusting
fetch().json()as typed API data. - Using several booleans instead of a discriminated union.
- Skipping the
neverexhaustiveness check in switches over unions. - Hand-written API interfaces that drift.
- Over-engineering types that nobody can read. Prefer simple types with clear names.
- 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
unknownat the edges. Narrow it or validate it. Avoidanyand uncheckedas. - Generics with
extendsandkeyofkeep types connected. Learn mapped types and conditional types withinfer. - Discriminated unions and
neverchecks make impossible states impossible and updates safe. as constandsatisfieskeep 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
- Basic Types, Inference and Narrowing · TypeScript From Zero
- Objects, Interfaces, Type Aliases and Classes · TypeScript From Zero
- Functions and Generics · TypeScript From Zero
Part of the study guide
Related series