You've seen one big interface — User with id, name, email, role, isActive — and watched it get used in many places. But real test code needs variations of that shape. The PATCH endpoint takes a partial User. The list view shows just id and name. The CREATE endpoint takes everything except id (the server generates it). Maintaining four hand-written interfaces for four variations is fragile. Utility types are TypeScript's built-in transformations: pass an interface in, get a related interface out. This lesson covers the five you'll use weekly.
A running interface to transform
Every example in this lesson starts from one User interface:
interface User {
id: number;
name: string;
email: string;
role: "admin" | "tester" | "viewer";
isActive: boolean;
}The utility types below produce variations of this — each tailored to a specific API shape.
Partial<T> — every field optional
Partial<T> walks every field of T and adds the optional ? modifier. Perfect for PATCH endpoints, test data overrides, and any helper that accepts a subset.
function updateUser(id: number, updates: Partial<User>): void {
// updates is { id?: number; name?: string; email?: string; role?: ...; isActive?: boolean }
}
updateUser(1, { name: "New Name" }); // ✅ only updating name
updateUser(1, { email: "new@test.com" }); // ✅ only email
updateUser(1, { name: "Alice", role: "admin" }); // ✅ both
updateUser(1, { banana: 42 });
// ❌ Object literal may only specify known properties, and 'banana' does not exist in type 'Partial<User>'.The override is type-checked against the original interface — you can omit fields, but you can't invent new ones. This is the ideal type for makeFixture<T>(template: T, overrides: Partial<T>)-style factories you saw in the previous lessons.
Required<T> — every field required
Required<T> is the inverse — strip every ? and force every field to be present. Useful when you have an interface with optional fields but a specific code path needs all of them filled in.
interface TestCase {
id: number;
title: string;
description?: string;
expectedResult?: string;
}
function publish(tc: Required<TestCase>): void {
console.log(tc.description); // safe — Required guarantees it's present
}
publish({ id: 1, title: "Login", description: "Logs in", expectedResult: "Dashboard shows" }); // ✅
publish({ id: 1, title: "Login" });
// ❌ Property 'description' is missing in type
// '{ id: number; title: string; }' but required in type 'Required<TestCase>'.The compiler enforces every field at the boundary. Inside publish, you can read tc.description.length without narrowing.
Pick<T, Keys> — keep only some fields
Pick<T, K> produces a new type with only the fields named by K. The keys must be a union of T's actual property names — keyof T again, the operator from the previous lesson.
type UserSummary = Pick<User, "id" | "name">;
// { id: number; name: string }
const alice: UserSummary = { id: 1, name: "Alice" };
const fuller: UserSummary = { id: 1, name: "Alice", email: "x" };
// ❌ Object literal may only specify known properties, and 'email' does not exist in type 'UserSummary'.Pick is the right tool for list views, dropdown options, and any consumer that only needs a slice of an entity. Build it from the canonical interface and a refactor later only has to update one place.
Omit<T, Keys> — drop some fields
Omit<T, K> is Pick's opposite — it produces a new type with the named fields removed. The classic use case is the request body for a CREATE endpoint:
type CreateUserInput = Omit<User, "id">;
// { name: string; email: string; role: ...; isActive: boolean }
async function createUser(input: CreateUserInput): Promise<User> {
// server generates the id and returns the full User
return { ...input, id: nextId() };
}
await createUser({ name: "Alice", email: "a@test.com", role: "admin", isActive: true });
// ✅ id correctly omitted from the inputOmit is the most common utility type in API testing. Send the request shape (without server-generated fields), receive the response shape (with them). One source of truth, two derived types.
Record<Keys, Type> — homogeneous map types
Record<K, V> describes an object where the keys come from a set and every value has the same type. Pure dictionaries.
type TestStatusMap = Record<string, "passed" | "failed" | "skipped">;
const results: TestStatusMap = {
"login-test": "passed",
"checkout-test": "failed",
"search-test": "skipped",
};
results["new-test"] = "passed"; // ✅
results["new-test"] = "broken";
// ❌ Type '"broken"' is not assignable to type '"passed" | "failed" | "skipped"'.When the keys are themselves a closed set (a literal union), Record becomes a strict map:
type Browser = "chromium" | "firefox" | "webkit";
type RetryByBrowser = Record<Browser, number>;
// { chromium: number; firefox: number; webkit: number }
const retries: RetryByBrowser = { chromium: 2, firefox: 1, webkit: 3 };
// Missing any key → compile errorUse Record for typed dictionaries — environment configs, retry maps, channel routing tables — anywhere the structure is "many keys, one value type."
Readonly<T> — every field readonly
You met readonly per-property in chapter 3. Readonly<T> applies it to every field at once:
const config: Readonly<User> = { id: 1, name: "Alice", email: "a@test.com", role: "admin", isActive: true };
config.role = "viewer";
// ❌ Cannot assign to 'role' because it is a read-only property.Useful for frozen test fixtures, configuration objects loaded once at startup, and any value where mutation would indicate a bug.
Composing utility types
The real power: utility types compose. Each takes a type and returns a type — chain them to get exactly the shape you need.
type CreateInput = Omit<User, "id">;
// no id (server generates it)
type UpdateInput = Partial<Omit<User, "id">>;
// optional fields, but you can't change id
type UserSummary = Pick<User, "id" | "name">;
// just the essentials for a list view
type FrozenSummary = Readonly<Pick<User, "id" | "name">>;
// list view + can't be mutatedA complete CRUD type system from one source interface. Add a field to User and every derived type updates automatically — no hunt-and-peck across files. This is utility types' biggest payoff.
What each utility does, at a glance
How each utility type transforms User { id, name, email, role, isActive }
| id | name | role | isActive | modifier | ||
|---|---|---|---|---|---|---|
| User (original) | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Partial<User> | ? | ? | ? | ? | ? | all optional |
| Required<User> | ✓ | ✓ | ✓ | ✓ | ✓ | all required |
| Pick<User, 'id'|'name'> | ✓ | ✓ | ✗ | ✗ | ✗ | kept 2 |
| Omit<User, 'id'> | ✗ | ✓ | ✓ | ✓ | ✓ | dropped 1 |
| Record<Browser, number> | — | — | — | — | — | K → V map |
A complete CRUD type system
The pattern that ties this whole chapter together — every variant of a User entity, derived from one source:
interface User {
id: number;
name: string;
email: string;
role: "admin" | "tester" | "viewer";
isActive: boolean;
createdAt: string;
}
type CreateUserInput = Omit<User, "id" | "createdAt">; // POST body
type UpdateUserInput = Partial<Omit<User, "id" | "createdAt">>; // PATCH body
type UserSummary = Pick<User, "id" | "name" | "role">; // list view
type ReadonlyUser = Readonly<User>; // immutable view
async function createUser(input: CreateUserInput): Promise<User> { /* ... */ throw 0; }
async function updateUser(id: number, input: UpdateUserInput): Promise<User> { /* ... */ throw 0; }
async function listUsers(): Promise<UserSummary[]> { /* ... */ throw 0; }A new field on User propagates to every derived type. The compiler walks every consumer pointing at the lines that need updating. Refactors stop being scary; they start being mechanical.
⚠️ Common mistakes
- Reaching for
anyto "fit" a partial update.function update(id: number, updates: any)accepts the wrong types.Partial<User>accepts a strict subset of User's fields. The two look similar in practice but theanyversion stops checking; thePartial<User>version still catches typos and wrong types. - Building variants by hand instead of derivations. Maintaining
interface CreateUser { ... }next tointerface User { ... }is a refactor liability — when you add a field to one, you have to remember to add it to the other (or remove it). Derive every variant withPick,Omit, orPartialinstead. - Misspelling a key in
PickorOmit.Pick<User, "emial">is a compile error:"emial"is not a key of User. The error message names the actual keys, which makes the typo trivial to fix — but it's a common stumble for beginners reaching for the utility types for the first time.
🎯 Practice task
Derive a complete CRUD type system. 25-35 minutes.
- In your
ts-for-qa/srcfolder, createcrud-types.ts. - Define
interface User { id: number; name: string; email: string; role: "admin" | "tester" | "viewer"; isActive: boolean; createdAt: string }. - Derive these variants using utility types only:
type CreateUserInput = Omit<User, "id" | "createdAt">type UpdateUserInput = Partial<Omit<User, "id" | "createdAt">>type UserSummary = Pick<User, "id" | "name" | "role">type FrozenUser = Readonly<User>
- Write stub
asyncfunctions forcreateUser,updateUser, andlistUsersusing the derived types as parameters and return types. - Call each with the right input shape. Confirm autocomplete works.
- Trigger every check. After each, read the error and revert:
- Pass an
idtocreateUser's input. - Pass a
bananafield toupdateUser's input. - Reassign a property of
FrozenUser. - Misspell a key in
Pick<User, "emial">.
- Pass an
- Build a
Recordmap:type RetryByBrowser = Record<"chromium" | "firefox" | "webkit", number>. Populate it and confirm omitting any key is a compile error. - Stretch: add
lastLoginAt: stringtoUser. Walk every derived type and observe which ones picked up the field automatically (Update, Frozen) and which are unchanged (Pick, Omit-of-specific-keys). This is the refactor payoff.
That wraps up chapter 5 — the heart of TypeScript. You can now write generic functions, generic interfaces, and use the utility types to derive everything. The next chapter is the advanced toolbox: type guards, assertion functions, mapped and conditional types — the patterns that make professional test code feel effortless.