Utility Types in TypeScript: Partial, Pick, Omit, Record

1. Overview

When writing TypeScript, you’ll encounter situations where you need a type similar to an existing type but missing or having extra fields. Instead of declaring a new interface, TypeScript provides a set of utility types to transform existing types. This article covers four of the most commonly used utility types: Partial, Pick, Omit, Record, along with practical examples and common pitfalls. After reading, you’ll know how to choose the right utility type for each situation instead of manually rewriting interfaces.

2. Partial<T>

Partial<T> makes all fields of T optional. Useful when writing update functions, where the caller only needs to pass some of the fields.

interface Customer {
  id: number;
  name: string;
  email: string;
  phone: string;
}

function updateCustomer(id: number, changes: Partial<Customer>): Customer {
  const current = findCustomerById(id); // assume this function already exists
  return { ...current, ...changes };
}

updateCustomer(1, { email: "new@mail.com" });

Partial<Customer> creates an equivalent type:

interface PartialCustomer {
  id?: number;
  name?: string;
  email?: string;
  phone?: string;
}

As a result, updateCustomer accepts an object with only email without reporting missing fields.

Note: Partial only makes fields optional at the first level (shallow). If Customer has a field that is a nested object, the inner fields remain required. To achieve deep partial, you must write a recursive type yourself or use a library like type-fest.

3. Pick<T, K>

Pick<T, K> selects a subset of fields from T according to the key list K. Use when you only need part of a large type’s data, for example, a DTO to return to the client.

interface Order {
  id: string;
  customerId: number;
  totalAmount: number;
  items: string[];
  createdAt: Date;
  internalNote: string;
}

type OrderSummary = Pick<Order, "id" | "totalAmount" | "createdAt">;

const summary: OrderSummary = {
  id: "ORD-001",
  totalAmount: 250_000,
  createdAt: new Date(),
};

OrderSummary only has 3 fields now. If you assign an extra field like items or internalNote to summary, the compiler will report an error Object literal may only specify known properties.

3.1. Pick with union keys

The key passed to Pick is a string literal union, so you can extract it into a separate variable for reuse:

type OrderPublicFields = "id" | "totalAmount" | "createdAt";

type OrderSummary = Pick<Order, OrderPublicFields>;
type OrderListItem = Pick<Order, OrderPublicFields | "customerId">;

This approach avoids repeating the key list in multiple places when you have multiple DTO variants from the same entity.

4. Omit<T, K>

Omit<T, K> does the opposite of Pick: removes specified keys and keeps the rest. Usually used when you want to hide sensitive or internal fields.

interface User {
  id: number;
  username: string;
  passwordHash: string;
  role: string;
}

type PublicUser = Omit<User, "passwordHash">;

function toPublicUser(user: User): PublicUser {
  const { passwordHash, ...rest } = user;
  return rest;
}

The line const { passwordHash, ...rest } = user separates passwordHash, leaving rest with only id, username, role — exactly matching the PublicUser type.

// Output when console.log(toPublicUser(user))
{ id: 1, username: "nonla_dev", role: "ADMIN" }

Note: Omit is defined based on Pick and Exclude, works well with object literal types but doesn’t properly remove overloads or discriminated unions in some complex cases. With union types (type A = B | C), Omit<A, K> will merge members before omitting, which can lose the discriminant property. When encountering complex unions, consider writing the type manually instead of using Omit directly.

5. Record<K, T>

Record<K, T> creates an object type with all keys belonging to K, where each key has a value of type T. Useful when modeling a map/dictionary with fixed keys.

type PaymentStatus = "PENDING" | "PAID" | "CANCELLED";

interface StatusInfo {
  label: string;
  color: string;
}

const statusDisplay: Record<PaymentStatus, StatusInfo> = {
  PENDING: { label: "Waiting for payment", color: "orange" },
  PAID: { label: "Paid", color: "green" },
  CANCELLED: { label: "Cancelled", color: "gray" },
};

function getStatusLabel(status: PaymentStatus): string {
  return statusDisplay[status].label;
}

Because statusDisplay is declared with type Record<PaymentStatus, StatusInfo>, the compiler requires you to have all three keys PENDING, PAID, CANCELLED. If you forget a key, TypeScript reports an error at compile time — this is a strength compared to using a regular { [key: string]: StatusInfo } (index signatures don’t check for complete keys).

5.1. Record with dynamic keys

When the key is not a union literal but a generic string, Record<string, T> is equivalent to an index signature:

const cache: Record<string, number> = {};
cache["order-1"] = 100;

In this case, there’s no longer a check for “complete keys” since string has infinite possible values. Only use Record<string, T> when you truly need a free-form dictionary; otherwise, prioritize union literals to leverage exhaustive checking.

6. Combining multiple utility types

Utility types can be nested together to create more complex types without writing new interfaces.

interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
  supplierNote: string;
}

type ProductUpdateRequest = Partial<Omit<Product, "id">>;

function updateProduct(id: string, data: ProductUpdateRequest) {
  // call repository.update(id, data)
}

updateProduct("P001", { price: 199_000 });

Omit<Product, "id"> removes the id field (prevent editing the primary key), then Partial makes the remaining fields optional. Result: the caller only needs to pass the fields they want to change and is not allowed to pass id.

7. Writing tests to verify behavior

Since utility types only exist at compile-time, the practical way to verify is to write unit tests for functions using those types to ensure their runtime behavior matches expectations. Example using Vitest:

<!-- package.json dependency equivalent (not Maven) -->
import { describe, it, expect } from "vitest";

interface User {
  id: number;
  username: string;
  passwordHash: string;
  role: string;
}

type PublicUser = Omit<User, "passwordHash">;

function toPublicUser(user: User): PublicUser {
  const { passwordHash, ...rest } = user;
  return rest;
}

describe("toPublicUser", () => {
  it("removes passwordHash from the result", () => {
    const user: User = {
      id: 1,
      username: "nonla_dev",
      passwordHash: "hash-123",
      role: "ADMIN",
    };

    const result = toPublicUser(user);

    expect(result).toEqual({
      id: 1,
      username: "nonla_dev",
      role: "ADMIN",
    });
    expect(result).not.toHaveProperty("passwordHash");
  });
});

This test confirms that the toPublicUser function is correct not only in terms of type but also in terms of runtime — passwordHash actually disappears from the returned object, not just being “hidden” at the type layer.

8. Common mistakes and when not to use

  • Confusing Pick/Omit with deleting real data: both only change type, they don’t affect the runtime object. Destructuring (const { x, ...rest } = obj) is what actually removes fields from the data.
  • Overusing Partial for objects returned from APIs: if the response always has all the fields, using Partial will force calling code to check for undefined unnecessarily everywhere. Only use Partial for input (update/patch), don’t use it for output that’s guaranteed to be complete.
  • Omit with complex union types: as mentioned in section 4, Omit can lose discriminated unions. If your type is a union of multiple variants (e.g., Shape = Circle | Square), write the type yourself or use conditional mapped type techniques instead of using Omit directly.
  • Record<string, T> instead of explicit interfaces: if you know all the keys upfront, declare a union literal and use Record<Key, T> for exhaustive checking. Using Record<string, T> arbitrarily will lose the ability to detect missing or extra keys at compile time.

Note: All these utility types only work at compile-time. After building to JavaScript, they disappear completely — no runtime overhead, but also no runtime validation. If data comes from outside (APIs, JSON files), you still need to validate using libraries like Zod or io-ts.

9. Conclusion

Partial, Pick, Omit, and Record help you reuse types instead of rewriting duplicate interfaces. Partial is suitable for update-style input, Pick/Omit for creating DTOs from larger entities, while Record is ideal for dictionaries with fixed keys, leveraging the compiler’s exhaustive checking. Understanding the limitations of each type — especially the shallow behavior of Partial and the union type issues of Omit — will help you avoid hard-to-debug type errors later.

typescriptutility-typestype-systemfrontend