TypeScript 유틸리티 타입 실무 가이드 — Partial, Pick, Omit 제대로 쓰기

목차

지난 화요일 오후, 입사 2주차 신입한테 Slack DM이 왔다. "선배, API 응답 타입을 잡는데 상황마다 필드가 달라서 인터페이스를 어떻게 만들어야 할지 모르겠어요." TypeScript 유틸리티 타입을 실무에서 제대로 안 쓰면 누구나 겪는 문제다. 코드를 열어보니 User 관련 인터페이스가 이미 7개였다 — UserListItem, UserDetail, UserUpdateRequest, UserCreateRequest, UserPartialUpdate, UserResponse, UserBasic. 필드 하나 바뀌면 7군데를 고쳐야 하는 구조.

이걸 보고 Partial, Pick, Omit 세 가지만 알면 대부분 정리된다고 알려줬는데, 그 과정에서 나도 다시 정리하게 된 내용을 여기 남긴다.

1. 왜 유틸리티 타입이 필요한가

REST API를 다루다 보면 하나의 엔터티가 여러 형태로 쓰인다. 같은 User라도 상황마다 모양이 다르다.

  • 목록 조회: id, name, email만 내려온다
  • 상세 조회: 전체 필드가 온다
  • 생성 요청: id, createdAt 빼고 보낸다
  • 수정 요청: 바꾸고 싶은 필드만 보낸다

이걸 매번 별도 interface로 만들면 어떻게 되는지 보자.

// ❌ 이렇게 하면 안 되는 이유
interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
  address: string;
  createdAt: string;
  updatedAt: string;
}

interface UserListItem {
  id: number;
  name: string;
  email: string;
}

interface UserCreateRequest {
  name: string;
  email: string;
  phone: string;
  address: string;
}

interface UserUpdateRequest {
  name?: string;
  email?: string;
  phone?: string;
  address?: string;
}

// ... 이런 식으로 계속 늘어난다

Usernickname 필드가 추가되면? 관련된 인터페이스를 전부 찾아서 고쳐야 한다. 빼먹으면 런타임에서 터진다. TypeScript를 쓰는 의미가 반감되는 순간이다.

TypeScript 유틸리티 타입은 이 문제를 위해 존재한다. 하나의 기본 타입에서 필요한 형태를 파생시키는 거다. 기본 타입만 수정하면 파생 타입도 자동으로 따라간다 (출처: TypeScript 공식 문서 Utility Types, TypeScript 5.4 기준).

한 줄 요약: 인터페이스를 복붙하지 말고, 하나의 소스에서 파생시켜라.

2. Partial — 부분 업데이트의 기본기

Partial<T>T의 모든 프로퍼티를 optional로 만든다. API에서 PATCH 요청 타입을 정의할 때 가장 먼저 떠올리면 된다.

interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
  address: string;
  createdAt: string;
  updatedAt: string;
}

// PATCH /users/:id 요청 바디
type UserUpdateRequest = Partial<User>;

// 모든 필드가 optional이 된다
const updateData: UserUpdateRequest = {
  name: "김철수",  // name만 보내도 OK
};

간단하고 직관적이다. 다만 이대로 쓰면 문제가 있다. idcreatedAt같이 클라이언트가 수정하면 안 되는 필드도 optional로 포함된다. 빈 객체 {}도 타입 에러 없이 통과해버린다.

Partial + Pick 조합

실무에서는 Partial 단독보다 Pick과 조합하는 경우가 훨씬 많다.

// 수정 가능한 필드만 골라서 Partial 적용
type UserUpdateRequest = Partial<Pick<User, 'name' | 'email' | 'phone' | 'address'>>;

// id, createdAt, updatedAt은 아예 포함 불가
const updateData: UserUpdateRequest = {
  name: "김철수",
  phone: "010-1234-5678",
};

// ❌ 컴파일 에러 — id는 수정 불가
const badUpdate: UserUpdateRequest = {
  id: 999,
  // Error: Type '{ id: number; }' is not assignable to type 'UserUpdateRequest'
};

이러면 클라이언트에서 실수로 id를 보내는 것 자체가 컴파일 단계에서 막힌다. 런타임 에러를 기다릴 필요가 없다.

Required — Partial의 반대

Required<T>는 모든 optional 프로퍼티를 필수로 바꾼다. 환경 변수 타입 정의할 때 가끔 유용하다.

interface AppConfig {
  apiUrl?: string;
  timeout?: number;
  retryCount?: number;
}

// 환경 변수 로딩 후에는 모든 값이 있어야 한다
type ValidatedConfig = Required<AppConfig>;

function initApp(config: ValidatedConfig) {
  // config.apiUrl이 반드시 string — undefined 체크 불필요
  fetch(config.apiUrl);
}

설정값이 처음엔 optional이지만 검증 후에는 필수인 경우에 딱 맞는 패턴이다.

3. Pick과 Omit — 필드를 고르거나 빼거나

Pick<T, K>는 특정 필드만 골라서 새 타입을 만든다. Omit<T, K>는 반대로 특정 필드를 빼고 나머지로 타입을 만든다. 둘 다 쓸 일이 많은데, 언제 뭘 써야 하는지가 은근 헷갈린다.

구분 Pick Omit
방식 명시한 필드만 포함 명시한 필드만 제외
적합한 상황 전체 중 소수만 필요할 때 전체 중 소수만 빼고 싶을 때
필드 추가 시 새 필드가 자동 포함 안 됨 새 필드가 자동 포함됨
안전성 더 엄격 (화이트리스트) 더 유연 (블랙리스트)
실무 예시 목록 API 응답 타입 생성 요청 타입 (id 제외)
// Pick: 목록 조회에서 필요한 필드만
type UserListItem = Pick<User, 'id' | 'name' | 'email'>;

// Omit: 생성 요청에서 서버가 만드는 필드 제외
type UserCreateRequest = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;

핵심 차이는 필드 추가 시 동작이다. Usernickname이 추가되면:

  • Pick<User, 'id' | 'name' | 'email'>은 변하지 않는다. nickname은 포함 안 됨.
  • Omit<User, 'id' | 'createdAt' | 'updatedAt'>nickname이 자동으로 포함된다.

이게 생각보다 중요하다. 목록 API 응답처럼 정해진 필드만 내려주는 경우에는 Pick이 안전하다. 반면 생성 요청처럼 "서버가 만드는 것만 빼고 전부 보내야 하는" 경우에는 Omit이 맞다. 새 필드가 추가됐을 때 생성 요청에도 자동으로 반영되니까.

한 줄 요약: "소수를 고른다" → Pick, "소수를 뺀다" → Omit. 필드 추가 시 자동 반영 여부가 판단 기준이다.

(개인적으로 5개 이하면 Pick, 그 이상이면 Omit을 쓰는 편인데, 이건 사람마다 기준이 다를 것 같다.)

4. 유틸리티 타입으로 실무 API 응답 타입 설계하기

여기서부터가 진짜 실무 이야기다. 신입한테 설명해주면서 나도 프로젝트 타입을 다시 정리했는데, 결국 패턴은 몇 가지로 수렴하더라.

패턴 1: 기본 엔터티 + 파생 타입

// 1. 기본 엔터티 정의 — DB 스키마와 1:1 대응
interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
  address: string;
  role: 'admin' | 'user' | 'guest';
  createdAt: string;  // ISO 8601
  updatedAt: string;
}

// 2. 서버 생성 필드 (재사용을 위해 타입으로 뽑아둔다)
type ServerGeneratedFields = 'id' | 'createdAt' | 'updatedAt';

// 3. 파생 타입들 — 이 4줄이 인터페이스 7개를 대체한다
type UserCreateRequest = Omit<User, ServerGeneratedFields>;
type UserUpdateRequest = Partial<Omit<User, ServerGeneratedFields>>;
type UserListItem = Pick<User, 'id' | 'name' | 'email' | 'role'>;
type UserDetail = User;  // 상세는 전체 필드 그대로

ServerGeneratedFields를 별도 타입으로 빼는 게 포인트다. Post, Comment 같은 다른 엔터티에서도 같은 패턴을 재사용할 수 있다.

패턴 2: API 응답 래퍼

백엔드 API 응답이 대부분 비슷한 구조를 가진다면 제네릭 래퍼를 만들면 편하다.

// API 응답 공통 구조
interface ApiResponse<T> {
  success: boolean;
  data: T;
  error?: {
    code: string;
    message: string;
  };
}

// 페이지네이션 포함 목록 응답
interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

// 사용 예시
type UserListResponse = PaginatedResponse<UserListItem>;
type UserDetailResponse = ApiResponse<UserDetail>;
type UserCreateResponse = ApiResponse<Pick<User, 'id'>>;

이 패턴을 쓰면 API 호출 함수의 반환 타입이 깔끔해진다.

// fetch 래퍼 함수들
async function getUsers(page: number): Promise<UserListResponse> {
  const response = await fetch(`/api/users?page=${page}`);
  return response.json();
}

async function getUser(id: number): Promise<UserDetailResponse> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

async function updateUser(
  id: number,
  data: UserUpdateRequest
): Promise<ApiResponse<UserDetail>> {
  const response = await fetch(`/api/users/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  return response.json();
}

신입한테 이 구조를 보여줬더니 "이러면 인터페이스 7개가 4줄로 줄어드는 거네요?"라는 반응이 돌아왔다. 정확히 그거다. 기본 User 타입 하나에서 전부 파생되니까, nickname 필드가 추가되면 User만 고치면 된다. UserCreateRequest에는 자동으로 nickname이 포함되고(Omit 덕분), UserListItem은 변하지 않는다(Pick 덕분).

패턴 3: 폼 상태 타입

프론트엔드에서 폼을 다룰 때도 유틸리티 타입이 쓸모 있다.

// 폼 진행 중 상태 — 아직 안 채운 필드가 있을 수 있음
type UserFormDraft = Partial<UserCreateRequest>;

// 폼 검증 후에는 모든 필드가 채워져야 한다
function validateForm(draft: UserFormDraft): draft is UserCreateRequest {
  return draft.name !== undefined
    && draft.email !== undefined
    && draft.phone !== undefined
    && draft.address !== undefined
    && draft.role !== undefined;
}

// 사용
function handleSubmit(draft: UserFormDraft) {
  if (!validateForm(draft)) {
    // 타입: UserFormDraft (Partial)
    alert("모든 필드를 입력해주세요");
    return;
  }
  // 여기서부터 타입: UserCreateRequest (모든 필드 필수)
  createUser(draft);  // ✅ 타입 에러 없음
}

Partial로 폼 진행 중 상태를 표현하고, 타입 가드로 검증 완료 상태를 확정짓는 구조다. validateForm을 통과한 후에는 TypeScript가 모든 필드가 존재한다고 추론한다.

5. 유틸리티 타입 조합 패턴과 커스텀 타입

유틸리티 타입은 조합해서 쓸 때 진가가 나온다. 자주 쓰이는 조합을 정리한다.

패턴 코드 용도
선택적 업데이트 Partial<Pick<T, K>> PATCH 요청 바디
생성 요청 Omit<T, 'id' | 'createdAt'> POST 요청 바디
읽기 전용 응답 Readonly<T> API 응답을 불변으로
필수 + 선택 혼합 Pick<T, A> & Partial<Pick<T, B>> 일부 필수, 일부 선택
Null 허용 { [K in keyof T]: T[K] | null } DB nullable 필드

"필수 + 선택 혼합"이 실무에서 꽤 쓰인다. 예를 들어 회원가입에서 name, email은 필수고 phone, address는 선택인 경우:

type SignupRequest = Pick<User, 'name' | 'email'>
  & Partial<Pick<User, 'phone' | 'address'>>;

// name, email은 필수 / phone, address는 선택
const signup: SignupRequest = {
  name: "김철수",
  email: "kim@example.com",
  // phone, address는 안 보내도 됨
};

&(intersection)으로 두 타입을 합치는 거다. interface extends보다 타입 조합 용도로는 이쪽이 더 유연하다.

커스텀 유틸리티 타입 만들기

프로젝트에서 반복되는 패턴이 있으면 커스텀 유틸리티 타입을 만드는 것도 괜찮다.

// 서버 생성 필드를 제외하는 범용 타입
type CreateRequest<T> = Omit<T, 'id' | 'createdAt' | 'updatedAt'>;

// 업데이트 요청 타입
type UpdateRequest<T> = Partial<CreateRequest<T>>;

// Nullable 버전 — DB에서 null이 올 수 있는 경우
type Nullable<T> = { [K in keyof T]: T[K] | null };

// 사용 — 새 엔터티마다 한 줄이면 된다
type PostCreateRequest = CreateRequest<Post>;
type PostUpdateRequest = UpdateRequest<Post>;
type CommentCreateRequest = CreateRequest<Comment>;

이렇게 해두면 새 엔터티가 추가될 때마다 한 줄로 요청 타입을 정의할 수 있다. (개인적으로 이런 유틸리티 타입은 types/utils.ts 파일에 모아두는 걸 선호한다.)

다만 커스텀 유틸리티 타입을 너무 많이 만들면 오히려 가독성이 떨어진다. DeepPartialReadonlyOmit<T, K> 같은 걸 만들기 시작하면 팀원이 타입 정의를 따라가기 힘들어진다. 2~3단계 이상 중첩되면 풀어서 쓰는 게 나을 수 있다.

Record와 Extract

Record<K, V>는 키-값 쌍 타입을 만들 때, Extract<T, U>는 유니온에서 특정 타입을 뽑을 때 쓴다.

// API 에러 코드별 메시지 매핑
type ErrorCode = 'NOT_FOUND' | 'UNAUTHORIZED' | 'VALIDATION_ERROR' | 'SERVER_ERROR';

const errorMessages: Record<ErrorCode, string> = {
  NOT_FOUND: "리소스를 찾을 수 없다",
  UNAUTHORIZED: "인증이 필요하다",
  VALIDATION_ERROR: "입력값이 올바르지 않다",
  SERVER_ERROR: "서버 오류가 발생했다",
};

// Extract: 유니온에서 특정 타입만 추출
type UserRole = 'admin' | 'user' | 'guest';
type AdminRole = Extract<UserRole, 'admin'>;  // 'admin'

// Exclude: 유니온에서 특정 타입 제거
type NonAdminRole = Exclude<UserRole, 'admin'>;  // 'user' | 'guest'

Record는 API 응답에서 맵 구조를 표현할 때 쓸모 있다. ExtractExclude까지 알면 실무에서 못 쓸 유틸리티 타입은 거의 없다.

(여담이지만 Awaited<T>는 TypeScript 4.5에서 추가됐는데, Promise 중첩을 풀 때 편하다. 이건 아직 깊게 안 써봐서 다음에 다룰 기회가 있으면 좋겠다.)

6. 흔한 실수 세 가지

실수 1: Omit에 오타를 넣어도 에러가 안 난다

이건 진짜 함정이다.

// ❌ 'createAt' — 오타인데 에러가 안 난다!
type Bad = Omit<User, 'id' | 'createAt'>;
// createdAt이 그대로 남아있음

TypeScript 5.4 기준으로도 이 동작은 바뀌지 않았다 (출처: TypeScript GitHub #30825). 방지하려면 strict한 Omit을 직접 만들어야 한다.

// 엄격한 Omit — 존재하지 않는 키를 넣으면 에러
type StrictOmit<T, K extends keyof T> = Omit<T, K>;

// ❌ 이제 에러가 난다
type Bad = StrictOmit<User, 'id' | 'createAt'>;
// Error: Type '"createAt"' is not assignable to type 'keyof User'

프로젝트 초기에 types/utils.ts에 넣어두면 나중에 삽질을 줄일 수 있다.

실수 2: Partial은 얕다

Partial<T>는 1단계 프로퍼티만 optional로 만든다. 중첩 객체 내부는 건드리지 않는다.

interface Company {
  name: string;
  address: {
    city: string;
    zipCode: string;
  };
}

type PartialCompany = Partial<Company>;

const update: PartialCompany = {
  address: { city: "서울" },
  // ❌ Error: Property 'zipCode' is missing
};

깊은 Partial이 필요하면 재귀 타입을 직접 만들어야 한다. 이 부분은 공식 유틸리티에 아직 없어서 좀 아쉬운 점이다.

실수 3: 타입 체조에 빠지기

여기까지 읽고 "이거 재밌는데?" 싶으면 위험 신호일 수 있다. 유틸리티 타입 조합에 빠지면 이런 코드가 나온다:

// ❌ 이러면 안 된다
type ComplexType = Readonly<
  Partial<
    Pick<Omit<User, 'id'>, Exclude<keyof Omit<User, 'id'>, 'createdAt'>>
  >
>;

이건 그냥 Readonly<Partial<Omit<User, 'id' | 'createdAt'>>>과 같다. 타입이 3단계 이상 중첩되면 한 발 물러서서 "풀어서 쓰면 더 읽기 쉬운가?"를 따져봐야 한다. 타입 시스템은 코드의 안전성을 위한 도구인데, 타입 자체를 읽기 어려우면 본말전도다.

한 줄 요약: Omit 오타 주의, Partial은 얕다, 3단계 이상 중첩은 자제.

TypeScript 유틸리티 타입 실무 적용은 결국 "하나의 소스 타입에서 파생"이라는 원칙 하나로 귀결된다. 이 원칙에 Partial, Pick, Omit 세 개만 붙이면 API 타입 설계의 체감 80%는 커버되고, 나머지는 Record, Extract 정도면 충분하다.

관련 글

Chiko IT
Chiko IT

Platform Engineer. Python, AI, Infra에 관심이 많습니다.