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

1. 개요

TypeScript를 작성하다 보면 기존 type과 비슷하지만 몇 개 field가 빠지거나 많은 type이 필요한 상황을 마주치게 됩니다. 새로운 interface를 선언하는 대신, TypeScript는 기존 type을 변환하기 위한 utility types 그룹을 제공합니다. 이 글에서는 가장 자주 사용되는 네 가지 utility type을 다룹니다: Partial, Pick, Omit, Record와 함께 실제 예제와 흔한 오류들을 살펴봅시다. 이 글을 읽고 나면, interface를 수동으로 다시 작성하는 대신 각 상황에 맞는 올바른 utility type을 선택할 수 있을 것입니다.

2. Partial<T>

Partial<T>는 T의 모든 field를 선택적으로 만듭니다. 호출자가 일부 field만 전달해야 하는 업데이트 함수를 작성할 때 유용합니다.

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

function updateCustomer(id: number, changes: Partial<Customer>): Customer {
  const current = findCustomerById(id); // 이 함수는 이미 존재한다고 가정
  return { ...current, ...changes };
}

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

Partial<Customer>는 다음과 동등한 type을 만듭니다:

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

이 덕분에 updateCustomer는 email만 있는 객체를 받아들일 수 있고 필드 누락에 대한 오류가 발생하지 않습니다.

주의: Partial은 1단계 깊이(shallow)에서만 선택적으로 만듭니다. Customer가 중첩된 객체 형태의 field를 가지고 있다면, 내부 field는 여전히 필수입니다. 깊은 partial이 필요하면 재귀적 type을 직접 작성하거나 type-fest 같은 라이브러리를 사용해야 합니다.

3. Pick<T, K>

Pick<T, K>는 key 목록 K에 따라 T에서 field의 부분집합을 선택합니다. 큰 type의 데이터 중 일부만 필요한 경우, 예를 들어 클라이언트에 반환할 DTO에 유용합니다.

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는 3개 field만 가집니다. summary에 items나 internalNote field를 더하려고 하면, 컴파일러가 Object literal may only specify known properties 오류를 발생시킵니다.

3.1. 유니온 key와 Pick 조합하기

Pick에 전달되는 key는 유니온 문자열 리터럴이므로, 재사용하기 위해 별도 변수로 분리할 수 있습니다:

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

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

이 방식은 동일한 entity에서 여러 DTO 변형이 필요할 때 여러 곳에서 key 목록이 반복되는 것을 방지합니다.

4. Omit<T, K>

Omit<T, K>는 Pick의 반대로 동작합니다: 지정된 key를 제거하고 나머지를 유지합니다. 보안에 민감한 field나 내부용 field를 숨길 때 자주 사용됩니다.

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;
}

const { passwordHash, ...rest } = user 줄은 passwordHash를 분리하고, rest는 id, username, role만 포함합니다 — PublicUser type과 정확히 일치합니다.

// console.log(toPublicUser(user))일 때의 출력
{ id: 1, username: "nonla_dev", role: "ADMIN" }

주의: Omit은 Pick과 Exclude를 기반으로 정의되며, 객체 리터럴 type에서는 잘 작동하지만 일부 복잡한 경우에 오버로드나 discriminated union을 올바르게 제거하지 못합니다. 유니온 type (type A = B | C)의 경우, Omit<A, K>는 omit을 수행하기 전에 member들을 병합할 수 있으며, 이는 discriminant 특성을 손상시킬 수 있습니다. 복잡한 유니온을 다룰 때는 Omit을 직접 사용하는 대신 type을 수동으로 작성하는 것을 고려하세요.

5. Record<K, T>

Record<K, T>는 K에 속하는 모든 key를 가지며 각 key의 값이 T 타입인 객체 type을 만듭니다. 고정된 key를 가진 map/dictionary를 모델링할 때 유용합니다.

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

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

const statusDisplay: Record<PaymentStatus, StatusInfo> = {
  PENDING: { label: "결제 대기 중", color: "orange" },
  PAID: { label: "결제 완료", color: "green" },
  CANCELLED: { label: "취소됨", color: "gray" },
};

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

statusDisplay가 Record<PaymentStatus, StatusInfo> type으로 선언되었으므로, 컴파일러는 반드시 PENDING, PAID, CANCELLED 세 key 모두가 있어야 합니다. 한 key를 빠뜨리면 TypeScript는 컴파일 시점에 즉시 오류를 표시합니다 — 일반적인 { [key: string]: StatusInfo } index signature를 사용하는 것보다 이것이 더 강력한 이유입니다 (index signature는 충분한 key를 검사하지 않습니다).

5.1. 동적 key를 가진 Record

key가 유니온 리터럴이 아니라 일반 string일 때, Record<string, T>는 index signature와 동등합니다:

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

이 경우 “충분한 key” 검사가 더 이상 이루어지지 않습니다. string은 무한한 값을 가질 수 있기 때문입니다. 정말로 자유로운 dictionary가 필요할 때만 Record<string, T>를 사용하고, 그 외에는 compiler의 exhaustive check를 활용하기 위해 유니온 리터럴을 우선시해야 합니다.

6. 여러 utility type 결합하기

utility type들을 서로 중첩시켜 새로운 interface를 작성하지 않고도 더 복잡한 type을 만들 수 있습니다.

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

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

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

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

Omit<Product, "id">는 id field를 제거하고 (기본 키는 수정할 수 없음), 그 다음 Partial은 나머지 field들을 선택적으로 만듭니다. 결과: 호출자는 변경하려는 field만 전달하면 되고, id는 전달할 수 없습니다.

7. 동작을 검증하는 테스트 작성

utility type은 compile-time에만 존재하므로, 검증하는 실제적인 방법은 이들 type을 사용하는 함수에 대해 unit test를 작성하고 runtime 동작이 예상대로 동작함을 보장하는 것입니다. Vitest를 사용한 예제입니다:

<!-- package.json 의존성 동등 (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("결과에서 passwordHash를 제거합니다", () => {
    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");
  });
});

이 테스트는 toPublicUser 함수가 type 면에서뿐만 아니라 runtime에서도 올바름을 확인합니다 — passwordHash는 실제로 반환된 객체에서 사라지며, type 수준에서만 “숨겨져” 있지 않습니다.

8. 흔한 오류와 언제 사용하지 말아야 할지

  • Pick/Omit을 실제 데이터 삭제로 착각하기: 둘 다 type만 변경하고 runtime 객체에는 영향을 주지 않습니다. Destructuring (const { x, ...rest } = obj)이 데이터에서 field를 실제로 제거합니다.
  • API에서 반환된 객체에 Partial 무분별하게 사용하기: response가 실제로 항상 모든 field를 가지고 있다면, Partial을 사용하면 호출 코드가 불필요하게 undefined를 여러 곳에서 검사해야 합니다. Partial은 input (update/patch)에만 사용하고, 충분함이 확실한 output에는 사용하지 마세요.
  • 복잡한 유니온 type에 Omit 사용하기: 섹션 4에서 언급했듯이, Omit은 discriminated union을 손상시킬 수 있습니다. type이 여러 변형의 유니온 (예: Shape = Circle | Square)인 경우, Omit을 직접 사용하는 대신 type을 수동으로 작성하거나 조건부 mapped type 기술을 사용하세요.
  • 명확한 interface 대신 Record<string, T> 사용하기: 미리 알고 있는 모든 key가 있다면, 유니온 리터럴을 선언하고 Record<Key, T>를 사용해 exhaustive check를 갖춰야 합니다. 자유롭게 Record<string, T>를 사용하면 compile 시 key가 누락되거나 초과되는 오류를 감지할 수 없게 됩니다.

주의: 이 모든 utility type은 compile-time에만 작동합니다. JavaScript로 빌드한 후에는 완전히 사라집니다 — runtime overhead가 없지만 runtime validation도 없습니다. 데이터가 외부에서 올 경우 (API, JSON 파일), Zod나 io-ts 같은 라이브러리를 사용해 검증해야 합니다.

9. 결론

Partial, Pick, Omit, Record는 type을 재사용하여 중복된 interface를 작성하는 것을 방지합니다. Partial은 update 형태의 input에 적합하고, Pick/Omit은 큰 entity에서 DTO를 만들 때, 그리고 Record는 고정된 key를 가진 dictionary에 이상적이며 compiler의 exhaustive check를 활용합니다. 각 type의 한계를 정확히 이해하기 — 특히 Partial의 shallow 동작과 Omit의 유니온 type 문제 — 는 나중에 디버깅하기 어려운 type 오류를 방지하는 데 도움이 됩니다.

typescriptutility-typestype-systemfrontend