Utility Types — Partial, Required, Pick, Omit, Record

9 min read

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 input

Omit 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 error

Use 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 mutated

A 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

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 any to "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 the any version stops checking; the Partial<User> version still catches typos and wrong types.
  • Building variants by hand instead of derivations. Maintaining interface CreateUser { ... } next to interface 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 with Pick, Omit, or Partial instead.
  • Misspelling a key in Pick or Omit. 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.

  1. In your ts-for-qa/src folder, create crud-types.ts.
  2. Define interface User { id: number; name: string; email: string; role: "admin" | "tester" | "viewer"; isActive: boolean; createdAt: string }.
  3. 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>
  4. Write stub async functions for createUser, updateUser, and listUsers using the derived types as parameters and return types.
  5. Call each with the right input shape. Confirm autocomplete works.
  6. Trigger every check. After each, read the error and revert:
    • Pass an id to createUser's input.
    • Pass a banana field to updateUser's input.
    • Reassign a property of FrozenUser.
    • Misspell a key in Pick<User, "emial">.
  7. Build a Record map: type RetryByBrowser = Record<"chromium" | "firefox" | "webkit", number>. Populate it and confirm omitting any key is a compile error.
  8. Stretch: add lastLoginAt: string to User. 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.

// tip to track lessons you complete and pick up where you left off across devices.