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 넷이면 대부분 되더라고요.
댓글