모든 글

TypeScript 유틸리티 타입 — 원본 하나로 Partial·Pick·Omit·Record 뽑아 쓰기

같은 interface를 복붙해 두세 번 정의하다 원본이 바뀌면 조용히 낡았다. 유틸리티 타입으로 "원본 하나만 두고 필요한 모양으로 깎아 쓰는" 법을, SCSS mixin에 비유해 정리한다.

제네릭이 타입을 끼워 넣는 빈칸이었다면, 유틸리티 타입은 TypeScript가 그 제네릭으로 미리 만들어 둔 기성품 가공 도구예요.

퍼블리셔 시절 .btn 하나 만들어두고 .btn--sm, .btn--ghost를 SCSS mixin으로 변형해 뽑던 것, 그거랑 똑같아요.

interface User를 복붙해서 두 번 세 번 정의하는 대신, 원본 하나만 두고 필요한 모양으로 깎아 씁니다.

원본이 이렇게 있다고 하면,

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

여기서 네 가지를 자주 써요.

// Omit — 특정 키를 빼낸다. 클라이언트로 내려보낼 때 민감 필드 제거
type PublicUser = Omit<User, 'password'>;

// Pick — 필요한 키만 골라낸다. 목록 카드에 쓸 필드만
type UserCardProps = Pick<User, 'id' | 'name'>;

// Partial — 모든 속성을 선택(optional)으로. 수정 폼: 바꾼 필드만 보낸다
type UserPatch = Partial<Pick<User, 'name' | 'email'>>;

// Record — "키 목록 → 값 타입" 객체를 만든다
type Status = 'idle' | 'loading' | 'error';
const badgeClass: Record<Status, string> = {
  idle: 'bg-gray-100',
  loading: 'bg-blue-100',
  error: 'bg-red-100',
};

이득은 원본이 바뀌면 파생 타입도 자동으로 따라 바뀐다는 거예요.

복붙한 타입은 원본이 바뀌어도 조용히 낡고, 그게 나중에 버그가 돼요.

원본 하나에서 파생시키면 그 위험이 사라지고요.

Record엔 숨은 이점이 하나 더 있어요.

위에서 Status에 'success'를 추가하는 순간, badgeClass가 그 키를 안 채웠다며 컴파일 에러가 나요.

상태 하나 추가하고 스타일 분기 깜빡하는 사고를 컴파일러가 막아주는 거죠.

직접 데인 함정도 셋 있어요.

Partial은 빈 객체 {}도 통과시켜요.

Partial<Pick<User, 'name' | 'email'>>는 두 필드가 다 optional이라 const a = {}가 합법이에요.

부분 수정엔 편하지만, 아무것도 안 보낸 요청까지 허용된다는 뜻이기도 하죠.

그래서 Partial<User>를 수정 DTO로 그냥 쓰면 위험해요.

type UpdateUserDto = Partial<User>;
// updateUser(1, { id: 999, password: 'x' }) 가 타입상 합법!

id랑 password까지 optional로 열려버려요.

먼저 Omit으로 잠그는 게 안전해요 — Partial<Omit<User, 'id' | 'password'>>.

마지막 하나.

Omit은 키 이름 오타를 안 잡아줘요.

Omit<User, 'passwrod'>처럼 오타를 내도 에러가 안 나요(두 번째 인자가 keyof T로 제한되지 않아서). 반면 Pick은 오타를 잡아주고요.

그래서 Omit을 쓸 땐 결과 타입을 한 번 눈으로 확인하는 버릇을 들였어요.

원본 타입 하나만 두고 API DTO, 컴포넌트 props, 폼 상태를 전부 파생시키면 타입 중복이 사라져요.

타입을 새로 쓰기 전에, 이거 원본에서 깎아낼 수 있나부터 의심해 보세요.

Pick·Omit·Partial·Record 넷이면 대부분 되더라고요.

N
NACRE
Writing on less · Seoul
다른 글 보기

댓글

  1. 불러오는 중…