유틸리티 타입 마스터: Partial, Pick, Omit, Record
똑같은 타입을 왜 또 만들고 있지?
사용자 관련 기능을 만들 때마다 이런 타입들을 작성하고 있었다.
interface User {
id: string;
email: string;
name: string;
age: number;
createdAt: Date;
}
interface UserCreate {
email: string;
name: string;
age: number;
}
interface UserUpdate {
email?: string;
name?: string;
age?: number;
}
interface UserResponse {
id: string;
email: string;
name: string;
}
뭔가 이상했다. 같은 필드를 계속 반복해서 쓰고 있는데, User 타입에 새 필드를 추가하면 다른 타입들도 일일이 수정해야 했다. phoneNumber를 추가하려면 네 곳을 다 손봐야 한다는 거다.
복사-붙여넣기로 타입을 만들고 있는 내가 보였다. 코드에서 중복은 적신호인데, 타입도 마찬가지 아닌가? 그때 유틸리티 타입을 알게 됐다.
레고처럼 타입을 조립한다
유틸리티 타입은 기존 타입을 변형해서 새 타입을 만드는 도구다. 마치 레고 블록을 조립하듯이, 이미 있는 타입에서 필요한 부분만 뽑거나 선택 사항으로 바꾸거나 합칠 수 있다.
TypeScript가 기본 제공하는 유틸리티 타입들을 익히고 나니, 타입 정의가 훨씬 간결해졌다. 무엇보다 한 곳만 수정하면 관련된 타입들이 자동으로 따라오니까 유지보수가 편해졌다.
Partial<T>: 모든 필드를 선택 사항으로
업데이트 폼을 만들 때 가장 많이 쓴다. 사용자가 이메일만 바꿀 수도 있고, 이름만 바꿀 수도 있으니까 모든 필드가 optional이어야 한다.
interface User {
id: string;
email: string;
name: string;
age: number;
}
// 이전: 일일이 ? 붙이기
interface UserUpdate {
id?: string;
email?: string;
name?: string;
age?: number;
}
// 이후: Partial로 한 방에
type UserUpdate = Partial<User>;
function updateUser(id: string, updates: Partial<User>) {
// { email: "new@example.com" } 만 넘겨도 OK
// { name: "New Name", age: 30 } 이것도 OK
}
모든 프로퍼티에 ?를 자동으로 붙여준다고 생각하면 된다. 설정 객체나 패치(patch) 요청에도 유용하다.
Pick<T, K>: 필요한 것만 뽑아내기
전체 타입에서 일부만 필요할 때 쓴다. API 응답에서 민감한 정보를 제외하거나, 특정 필드만 있는 요약 데이터를 만들 때 유용하다.
interface User {
id: string;
email: string;
name: string;
password: string;
createdAt: Date;
}
// 공개 프로필: 이름과 ID만
type PublicProfile = Pick<User, 'id' | 'name'>;
// 로그인 폼: 이메일과 비밀번호만
type LoginForm = Pick<User, 'email' | 'password'>;
const profile: PublicProfile = {
id: '123',
name: 'John',
// email: 'test@test.com' // 에러! PublicProfile에는 email이 없음
};
마치 과일 바구니에서 원하는 과일만 골라 담는 것처럼, 타입에서 필요한 필드만 선택한다.
Omit<T, K>: 빼고 싶은 것만 제거
Pick의 반대다. "이것만 빼고 다 가져가"할 때 쓴다. 생성 요청처럼 서버가 자동으로 만들어주는 필드를 제외할 때 많이 쓴다.
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
// 생성 요청: id, 날짜는 서버가 만듦
type UserCreate = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
const newUser: UserCreate = {
email: 'new@example.com',
name: 'New User',
// id나 createdAt을 넣으면 에러
};
Pick과 Omit 중 뭘 쓸지는 간단하다. 필요한 게 적으면 Pick, 빼야 할 게 적으면 Omit이다.
Record<K, V>: 동적 키-값 객체
객체의 키와 값 타입을 지정할 때 쓴다. 설정 맵, 룩업 테이블, 캐시 객체처럼 키를 미리 모르는 경우에 유용하다.
// 사용자 ID를 키로, User 객체를 값으로
type UserMap = Record<string, User>;
const users: UserMap = {
'user-1': { id: 'user-1', email: 'a@test.com', name: 'Alice', age: 25, createdAt: new Date() },
'user-2': { id: 'user-2', email: 'b@test.com', name: 'Bob', age: 30, createdAt: new Date() },
};
// 특정 키만 허용
type Theme = 'light' | 'dark' | 'auto';
type ThemeConfig = Record<Theme, { background: string; text: string }>;
const themeConfig: ThemeConfig = {
light: { background: '#fff', text: '#000' },
dark: { background: '#000', text: '#fff' },
auto: { background: '#f5f5f5', text: '#333' },
// 'custom' 같은 다른 키는 에러
};
{ [key: string]: Value } 인덱스 시그니처보다 명시적이고 읽기 쉽다.
Required<T>: 모든 필드를 필수로
Partial의 정반대다. 선택 사항이었던 필드들을 모두 필수로 만든다.
interface Config {
host?: string;
port?: number;
debug?: boolean;
}
// 런타임에 모든 값이 설정되었는지 확인 후
type ValidatedConfig = Required<Config>;
function startServer(config: ValidatedConfig) {
// config.host는 반드시 있음 (? 없음)
console.log(config.host.toUpperCase()); // 안전
}
선택적 설정을 받아서 기본값을 채운 뒤, 완전한 설정 객체로 만들 때 유용하다.
Readonly<T>: 불변 객체 만들기
한 번 만들면 수정 못 하게 막는다. 상수 설정이나 이벤트 객체처럼 변경되면 안 되는 데이터에 쓴다.
interface Point {
x: number;
y: number;
}
const origin: Readonly<Point> = { x: 0, y: 0 };
origin.x = 10; // 에러! readonly 프로퍼티는 수정 불가
// 배열도 가능
const numbers: ReadonlyArray<number> = [1, 2, 3];
numbers.push(4); // 에러!
실수로 데이터를 바꾸는 걸 컴파일 타임에 잡아준다.
ReturnType<T>과 Parameters<T>: 함수에서 타입 추출
함수 시그니처에서 타입을 뽑아낸다. 함수를 먼저 정의하고, 그 함수의 리턴 타입이나 파라미터 타입을 재사용할 때 쓴다.
function createUser(email: string, name: string) {
return {
id: Math.random().toString(),
email,
name,
createdAt: new Date(),
};
}
// 함수의 리턴 타입 추출
type User = ReturnType<typeof createUser>;
// { id: string; email: string; name: string; createdAt: Date }
// 함수의 파라미터 타입 추출
type CreateUserParams = Parameters<typeof createUser>;
// [email: string, name: string]
// 첫 번째 파라미터만
type Email = Parameters<typeof createUser>[0]; // string
함수가 리턴하는 복잡한 객체를 타입으로 쓰고 싶을 때, 중복 없이 타입을 정의할 수 있다.
실제: 하나의 베이스 타입에서 모든 타입 파생
이제 처음 문제로 돌아가보자. User 관련 타입들을 유틸리티 타입으로 다시 만들면 이렇게 된다.
// 1. 베이스 타입 하나만 정의
interface User {
id: string;
email: string;
name: string;
age: number;
password: string;
createdAt: Date;
updatedAt: Date;
}
// 2. 나머지는 유틸리티 타입으로 파생
type UserCreate = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UserUpdate = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
type UserResponse = Omit<User, 'password'>;
type PublicProfile = Pick<User, 'id' | 'name'>;
type UsersMap = Record<string, User>;
// 3. User 타입만 수정하면 다른 타입들도 자동 갱신
// phoneNumber 추가하면 UserCreate, UserUpdate에도 자동 반영!
타입 정의가 선언적이 됐다. "UserCreate는 User에서 id랑 날짜 필드를 뺀 거다"라고 의도가 명확히 드러난다. 그리고 User에 새 필드를 추가하면 관련된 모든 타입이 자동으로 업데이트된다.
유틸리티 타입 조합하기
여러 유틸리티 타입을 조합하면 복잡한 타입도 간결하게 만들 수 있다.
// 일부 필드는 필수, 일부는 선택
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
interface Product {
id: string;
name: string;
price: number;
description: string;
}
// price, description만 선택 사항으로
type ProductDraft = PartialBy<Product, 'price' | 'description'>;
const draft: ProductDraft = {
id: '1',
name: 'Laptop',
// price, description은 optional
};
이런 커스텀 유틸리티 타입도 만들 수 있다.
어떻게 작동하는가: Mapped Types
유틸리티 타입들은 내부적으로 맵드 타입(Mapped Types)으로 구현되어 있다. 타입의 각 프로퍼티를 순회하면서 변형하는 방식이다.
// Partial의 실제 구현
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Pick의 실제 구현
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Record의 실제 구현
type Record<K extends keyof any, T> = {
[P in K]: T;
};
[P in keyof T]는 T의 모든 키를 순회한다는 뜻이다. 마치 배열의 map 함수처럼 타입의 각 필드를 하나씩 처리한다. ?를 붙이면 선택 사항이 되고, 특정 키만 선택하면 Pick처럼 동작한다.
이 원리를 이해하면 자신만의 유틸리티 타입을 만들 수도 있다.
// 모든 값을 Promise로 감싸기
type Promisify<T> = {
[P in keyof T]: Promise<T[P]>;
};
interface SyncAPI {
getUser: User;
getPost: Post;
}
type AsyncAPI = Promisify<SyncAPI>;
// { getUser: Promise<User>; getPost: Promise<Post> }
정리: 타입도 재사용이다
유틸리티 타입을 쓰기 전에는 타입을 복사-붙여넣기로 만들고 있었다. 하나 수정하면 여러 곳을 다 고쳐야 했고, 실수도 잦았다.
유틸리티 타입을 알고 나니 타입도 코드처럼 재사용할 수 있다는 게 와닿았다. 베이스 타입 하나만 잘 정의하면, 나머지는 Partial, Pick, Omit, Record로 조합해서 만들면 된다.
핵심 정리:
Partial<T>: 모든 필드를 선택 사항으로 (업데이트, 패치)Required<T>: 모든 필드를 필수로 (기본값 채운 후 검증)Pick<T, K>: 특정 필드만 선택 (공개 데이터, 요약)Omit<T, K>: 특정 필드만 제외 (생성 요청, 민감 정보 제거)Record<K, V>: 키-값 맵 (캐시, 설정, 룩업 테이블)Readonly<T>: 불변 객체 (상수, 이벤트)ReturnType<T>,Parameters<T>: 함수 시그니처에서 타입 추출
타입 정의가 간결해지니 코드가 읽기 쉬워졌고, 한 곳만 수정하면 되니 유지보수도 편해졌다. 결국 TypeScript를 쓰는 이유도 이거다. 타입 시스템이 리팩토링을 안전하게 만들어주고, 유틸리티 타입은 그걸 더 쉽게 만들어준다.
이제 비슷한 타입을 또 만들려고 할 때마다, "이거 유틸리티 타입으로 만들 수 있지 않나?"라고 먼저 생각하게 됐다.