Utility types in practice
Partial, Readonly, Pick, Omit, Record and the types you derive from functions, with the gaps each one leaves at runtime.
After this lesson you can
- Pick the right built-in utility type for a patch, a view or a lookup table
- Derive types from a function with ReturnType, Parameters and Awaited
- Explain what Partial and Readonly do not guarantee at runtime
- Use keyof and indexed access to write a type-safe helper
The generics lesson listed the common utility types. This one is about using them well, and about what each one does not promise.
Shapes derived from a shape
Given one User type, most of the others in an application are views of
it:
interface User { id: string; name: string; email: string; plan: "free" | "pro" }
type UserPatch = Partial<Omit<User, "id">>; // any subset, id excluded
type UserSummary = Pick<User, "id" | "name">; // a narrow view
type UsersById = Record<string, User>; // a lookup table
type FrozenUser = Readonly<User>; // no assignments
Deriving them means a change to User flows everywhere. Writing them out
by hand means four types to keep in sync.
Partial allows undefined, not just absence
Partial<T> makes each property optional, and under default settings an
optional property also accepts an explicit undefined. A patch built from
a form or a query string often has exactly that, and spreading it
overwrites the field:
interface User { name: string; email: string; plan: "free" | "pro";} function update(user: User, patch: Partial<User>): User { return { ...user, ...patch };} export function run(): unknown[] { const user: User = { name: "Nino", email: "nino@example.com", plan: "free" }; const upgraded = update(user, { plan: "pro" }); // Partial<User> allows a key that is present but undefined, and spread copies it. const blanked = update(user, { name: undefined }); return [upgraded, "name" in blanked, String(blanked.name)];}Either skip undefined values when applying the patch, or turn on
exactOptionalPropertyTypes so the type forbids an explicit undefined.
Readonly is not freeze
Readonly<T> blocks assignment through that type. Object.freeze blocks
it at runtime. Only one of them survives a cast:
interface Point { x: number; y: number;} export function run(): string[] { const typed: Readonly<Point> = { x: 1, y: 2 }; const frozen = Object.freeze({ x: 1, y: 2 }); const out: string[] = []; try { (typed as Point).x = 99; out.push(`Readonly: x is now ${typed.x}`); } catch (e) { out.push(`Readonly: ${String(e)}`); } try { (frozen as Point).x = 99; out.push(`frozen: x is now ${frozen.x}`); } catch (e) { out.push(`frozen: ${String(e)}`); } return out;}The frozen object throws because module code runs in strict mode; in
sloppy-mode scripts the assignment is silently ignored instead. Both
Readonly and Object.freeze are shallow: nested objects stay mutable.
Types derived from functions
ReturnType<typeof f> and Parameters<typeof f> read a function's types,
and Awaited<T> unwraps a promise (recursively). They let the
implementation be the source of truth:
function createInvoice(customer: string, lines: { price: number; qty: number }[]) { const total = lines.reduce((sum, l) => sum + l.price * l.qty, 0); return { customer, total, currency: "EUR" as const };} // Derived from the function, so they cannot drift away from it.type Invoice = ReturnType<typeof createInvoice>;type InvoiceInput = Parameters<typeof createInvoice>; export function run(): Invoice[] { const inputs: InvoiceInput[] = [ ["Nino", [{ price: 10, qty: 2 }]], ["Luka", [{ price: 5, qty: 1 }, { price: 7, qty: 3 }]], ]; return inputs.map((args) => createInvoice(...args));}For an async function, the result type is
Awaited<ReturnType<typeof loadUser>>.
keyof and indexed access
The building blocks under all of these: keyof T is the union of T's
keys, and T[K] is the type of property K. Together they type a helper
whose result depends on the key passed:
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
get(user, "plan"); // "free" | "pro"
get(user, "nmae"); // compile error: not a key of User
Common mistakes
- Spreading a
Partialpatch blindly. An explicitundefinedwipes the field, as the first example shows. - Expecting
Readonlyto stop mutation. A cast or a mutable alias gets through; onlyObject.freezeholds at runtime, and only one level deep. Omitwith a misspelt key.Omit<User, "emial">is not an error (its key parameter accepts any string) and removes nothing.Pickdoes check its keys.- Rewriting a function's return type by hand. It drifts; derive it
with
ReturnTypeinstead.
Exercise · Pro
Only the fields that changed
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade