This is the lesson you'll read more than write. Mapped types transform every property of an existing type. Conditional types pick one type or another based on a check. Together they're the engine behind every utility type you've already used (Partial, Pick, Omit, Required) and the typing tricks Cypress and Playwright use under the hood. You don't need to write these from scratch often — but you'll read them constantly, and understanding the mechanics turns library type definitions from runes into prose.
Mapped types — transforming every property
A mapped type walks every property of an existing type and produces a new property in the result. The syntax uses [K in keyof T] — read it as "for each key K in the keys of T."
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
interface User { id: number; name: string; email: string }
type PartialUser = MyPartial<User>;
// equivalent to:
// { id?: number; name?: string; email?: string }That's the entire definition of Partial<T>. TypeScript has it built in — Partial<User> is the canonical version — but writing it yourself shows the machinery. Three tokens worth knowing:
keyof T— the union of property names ofT("id" | "name" | "email").[K in ...]— the iteration over those keys.T[K]— the type of T's property named K (an indexed access type).
Combine them with the ? (optional) or readonly modifiers, and you can build whichever shape transformation you need.
Practical mapped types
A Nullable<T> that allows null in every field — useful for "patch" payloads where any field can be cleared:
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }A Stringified<T> where every field is the string version (handy when an API returns everything serialised):
type Stringified<T> = {
[K in keyof T]: string;
};A more advanced trick — derive setter function names from property names using template literal types:
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
type UserSetters = Setters<User>;
// {
// setId: (value: number) => void;
// setName: (value: string) => void;
// setEmail: (value: string) => void;
// }The as clause renames the key during mapping. Capitalize is a built-in utility that uppercases the first character of a string literal. You probably won't write this from scratch — but setName: (v: string) => void is exactly what page-object frameworks generate, and now you can read the source.
Conditional types — types that branch
A conditional type uses the ternary syntax in the type system: T extends U ? X : Y. Read it as "if T is assignable to U, the result is X; otherwise Y."
type IsArray<T> = T extends unknown[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<number>; // falseThe compiler evaluates the condition at type level. IsArray<T> is a true/false type that depends on whether T is an array.
A more useful example — extract the element type from an array, using the infer keyword to ask TypeScript to figure out the element type for you:
type ElementType<T> = T extends (infer E)[] ? E : T;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<User[]>; // User
type D = ElementType<number>; // number (not an array — return T as-is)The infer E declares a type variable inside the condition. If TypeScript can match the pattern (any array, with element type E), it binds E to the element type and returns it. If it can't, the : T branch produces the original input.
infer is the trick that powers ReturnType<T>, Awaited<T>, Parameters<T>, and most of the advanced built-ins. You don't have to use it yourself — but recognising it explains how TypeScript "knows" the resolved value of a Promise, the parameters of a function, the element type of an iterable.
The TypeScript type system, ranked by daily use
- – string, number, boolean
- – interface User { ... }
- – type Status = 'p' | 'f' | 's'
- – Array<T>, Promise<T>
- – Partial, Pick, Omit, Record
- – interface ApiResponse<T>
- – typeof, instanceof, in
- – function isUser(v): v is User
- – function assertX(v): asserts v is X
- Mapped: [K in keyof T] –
- Conditional: T extends U ? X : Y –
- infer E inside conditionals –
You're at the edge of the type system here. Most QA codebases use the inner three rings every day; the outer ring is library territory you'll mostly read. That's fine — knowing the patterns exist is enough to debug the compiler errors when they show up.
A real example from the wild
Cypress's Chainable type uses generics, mapped types, and conditional types together. A simplified excerpt of what you might find in @types/cypress:
// Simplified shape — the real one is more elaborate
interface Chainable<Subject = any> {
get<E extends Node = HTMLElement>(selector: string): Chainable<JQuery<E>>;
/* … */
}Walking it: Chainable is a generic interface. <Subject = any> is a type parameter with a default. get is itself generic over E, constrained to extend Node, and returns a chainable wrapping JQuery<E>. Every piece is something you've now learned. Reading framework type files becomes possible — even when you wouldn't write them from scratch.
When to actually write a mapped or conditional type
The honest answer for QA work: rarely. Reach for them when:
- You need a transformation the built-in utilities don't cover. The five built-ins (
Partial,Required,Pick,Omit,Record) plusReadonlycover ~95% of cases. The remaining 5% — string-keyed setters, deep-readonly, conditional response types — is where mapped types earn their keep. - A pattern repeats across many interfaces. If three different interfaces all need a "make every field nullable" variant, factor it into a
Nullable<T>mapped type instead of writing it three times. - You're typing a library or framework helper. Public APIs benefit from precise types — your own private test code mostly doesn't.
If you can solve the problem with a built-in utility type, do that. Mapped and conditional types are powerful but expensive to read; reach for them only when nothing simpler fits.
A QA-shaped mapped type
A practical helper for test data — derive a "patch input" type that allows null on every field except the ID:
type PatchInput<T extends { id: string | number }> = {
[K in keyof T]?: K extends "id" ? T[K] : T[K] | null;
};
interface User { id: number; name: string; email: string; phone?: string }
type UserPatch = PatchInput<User>;
// {
// id?: number;
// name?: string | null;
// email?: string | null;
// phone?: string | null;
// }
const patch: UserPatch = { name: null, email: "new@test.com" }; // ✅ name set to nullMapped type to make every field optional, conditional type inside to keep id from going null. Three lines of type code that capture a real API contract. Compare to maintaining the UserPatch interface by hand and updating it every time User gains a field — and the leverage is obvious.
⚠️ Common mistakes
- Reaching for mapped types when
PartialorPickwould do. If you can express the transformation with a built-in utility, prefer it.Partial<User>reads at a glance;{ [K in keyof User]?: User[K] }does the same thing more verbosely. - Confusing the type-level
extendswith the value-levelextends. In conditional typesT extends U ? X : Yis a type check ("is T assignable to U?"). Ininterface AdminUser extends BaseUserit's inheritance ("AdminUser inherits from BaseUser"). Same keyword, different worlds — context tells you which is which. - Trying to debug a mapped type at runtime. Mapped and conditional types are pure compile-time machinery. They produce zero runtime code. You can't
console.log(MyPartial<User>)to inspect the shape — hover over a value of that type in VS Code instead. The IDE evaluates the type and shows the resolved shape in the tooltip.
🎯 Practice task
Read more than you write. 25-35 minutes.
- In your
ts-for-qa/srcfolder, createmapped.ts. - Re-implement
Partial,Required,Readonly, andPickfrom scratch using mapped types. Name eachMy...to avoid clashing with the built-ins. Verify your versions behave the same as the built-ins by aliasing them to the sameUserinterface and hovering over the resulting types in VS Code. - Implement
Nullable<T>— every property becomesT[K] | null. - Implement
ElementType<T>— extracts the element type from an array, leaves non-arrays alone (useT extends (infer E)[] ? E : T). - Use
ElementTypewithstring[],number[][],User[], and a non-array type. Confirm each result matches your prediction. - Look at one library type definition. Open
node_modules/@playwright/test/types/test.d.ts(ornode_modules/cypress/types/cy.d.ts). Find a generic interface —Page,Chainable, anything with<T>. Hover over its declaration and list the techniques from this chapter you can spot. Don't try to understand every line; the goal is recognising the patterns. - Stretch: write a
DeepReadonly<T>mapped type that makes every nested object's properties readonly too. Hint: use a conditional type to recursively apply itself whenT[K]is an object. This is one of the most useful real mapped types — and a perfect way to feel the limits of when handwritten types stop being worth it.
That's chapter 6. You can now narrow union types confidently, build assertion helpers, and read the mapped-and-conditional gymnastics in framework type files. The next chapter takes everything you've learned and applies it to real Cypress and Playwright projects — page objects, fixtures, test data factories.