REST API를 TypeScript로 타이핑하다 보면 비슷한 타입을 계속 만들게 된다. User는 있는데, 생성 요청에는 id가 없고, 수정 요청에는 일부 필드만 있고, 목록 조회에는 몇 개 필드만 필요하다. 이걸 매번 새 타입으로 만들면 User가 바뀔 때 다 같이 바꿔야 한다.
TypeScript는 이 문제를 유틸리티 타입으로 해결한다. 기존 타입을 변환해서 새 타입을 만드는 내장 헬퍼들이다.
Partial<T> — 모든 프로퍼티를 선택적으로
PATCH 요청처럼 일부 필드만 전송하는 경우에 쓴다.
interface User {
id: number
name: string
email: string
age: number
}
type UserUpdate = Partial<User>
// = { id?: number; name?: string; email?: string; age?: number }
async function updateUser(id: number, data: Partial<User>) {
// data에는 바꾸려는 필드만 있으면 됨
}
updateUser(1, { name: '새 이름' }) // OK
updateUser(1, { name: '이름', age: 25 }) // OK
Required<T> — 모든 프로퍼티를 필수로
선택적 프로퍼티를 전부 필수로 바꿔야 할 때 쓴다.
interface Config {
host?: string
port?: number
debug?: boolean
}
type StrictConfig = Required<Config>
// = { host: string; port: number; debug: boolean }
Readonly<T> — 읽기 전용으로 고정
불변 객체가 필요할 때 쓴다.
interface Cart {
items: CartItem[]
total: number
}
type FrozenCart = Readonly<Cart>
const cart: FrozenCart = { items: [], total: 0 }
cart.total = 100 // ❌ 컴파일 오류
Pick<T, K> — 필요한 프로퍼티만 선택
interface User {
id: number
name: string
email: string
password: string
createdAt: string
}
// 목록 조회 — id, name만 필요
type UserSummary = Pick<User, 'id' | 'name'>
// = { id: number; name: string }
// 프로필 카드 — id, name, email
type UserCard = Pick<User, 'id' | 'name' | 'email'>
Omit<T, K> — 불필요한 프로퍼티를 제외
// 생성 요청 — id, createdAt은 서버가 자동 생성
type CreateUserRequest = Omit<User, 'id' | 'createdAt'>
// = { name: string; email: string; password: string }
// 수정 요청 — id는 URL에 있고, 나머지는 선택적
type UpdateUserRequest = Partial<Omit<User, 'id' | 'createdAt'>>
// = { name?: string; email?: string; password?: string }
Pick vs Omit 선택 기준: 남길 필드가 적으면
Pick, 제거할 필드가 적으면Omit. 필드가 10개인데 9개 필요하면Omit이 훨씬 간결하다.
Record<K, V> — 키-값 맵
// string 키에 number 값
type ScoreBoard = Record<string, number>
const scores: ScoreBoard = { alice: 100, bob: 85 }
// 유니온 키 — 모든 키가 반드시 있어야 함
type OrderStatus = 'PENDING' | 'PAID' | 'SHIPPED' | 'DELIVERED' | 'CANCELLED'
type StatusLabel = Record<OrderStatus, string>
const labels: StatusLabel = {
PENDING: '결제 대기',
PAID: '결제 완료',
SHIPPED: '배송 중',
DELIVERED: '배송 완료',
CANCELLED: '취소됨',
// 하나라도 빠지면 컴파일 오류
}
유니온을 키로 쓰면 모든 케이스를 커버했는지 컴파일러가 확인해준다. 새 상태가 추가되면 labels도 함께 채워야 한다.
Exclude / Extract — 유니온 필터링
type AllRoles = 'admin' | 'user' | 'guest' | 'moderator'
// Exclude — 제거
type NonAdminRoles = Exclude<AllRoles, 'admin'>
// = 'user' | 'guest' | 'moderator'
// Extract — 추출
type StaffRoles = Extract<AllRoles, 'admin' | 'moderator'>
// = 'admin' | 'moderator'
// NonNullable — null, undefined 제거
type SafeString = NonNullable<string | null | undefined>
// = string
ReturnType / Parameters — 함수 타입 추출
함수 시그니처를 직접 타이핑하지 않고 타입을 추출할 수 있다.
function fetchUser(id: number): Promise<User> {
return fetch(`/api/users/${id}`).then(r => r.json())
}
type FetchReturn = ReturnType<typeof fetchUser>
// = Promise<User>
type FetchParams = Parameters<typeof fetchUser>
// = [id: number]
// Awaited — Promise를 풀어서 내부 타입 추출
type UserType = Awaited<ReturnType<typeof fetchUser>>
// = User
라이브러리 함수의 반환 타입을 재활용할 때 유용하다. 함수 시그니처가 바뀌면 ReturnType도 자동으로 따라간다.
실무 조합 패턴
위 유틸리티 타입들을 조합하면 Article 하나에서 여러 용도의 타입을 파생시킬 수 있다.
interface Article {
id: number
title: string
content: string
authorId: number
tags: string[]
published: boolean
createdAt: string
updatedAt: string
}
// 목록 API 응답
type ArticleListItem = Pick<Article, 'id' | 'title' | 'authorId' | 'tags' | 'createdAt'>
// 생성 요청
type CreateArticleDto = Pick<Article, 'title' | 'content' | 'tags'>
// 수정 요청 — 제목, 내용, 태그만 수정 가능, 모두 선택적
type UpdateArticleDto = Partial<Pick<Article, 'title' | 'content' | 'tags'>>
// 임시 저장 — id, 타임스탬프 없는 상태
type ArticleDraft = Omit<Article, 'id' | 'createdAt' | 'updatedAt'>
Article 인터페이스만 수정하면 나머지는 자동으로 따라간다.
마치며
유틸리티 타입은 “기존 타입에서 파생"한다는 발상이다. 처음부터 모든 타입을 손으로 만들지 않아도 된다. Partial, Pick, Omit만 익혀도 API 타이핑의 80%는 해결된다.
다음 글에서는 유니온 타입을 런타임에 안전하게 처리하는 타입 가드와, 상태 관리에서 자주 쓰이는 Discriminated Union 패턴을 다룬다.