본문 바로가기

Engineering/Frontend

타입 중복 없애는 TypeScript 유틸리티 타입 정리

#TypeScript #유틸리티 타입 #Partial #Pick #Omit #프론트엔드
TypeScript 유틸리티 타입을 잘 쓰면 타입 중복 없이 유연하고 안전한 코드를 만들 수 있습니다.

TypeScript를 쓰다 보면 비슷한 타입을 조금씩 변형해서 여러 곳에 쓰는 상황이 자주 생깁니다. 이럴 때마다 새 타입을 만들면 코드가 중복되고, 원본 타입이 바뀌면 파생 타입도 일일이 수정해야 합니다. 유틸리티 타입(Utility Types)은 이런 문제를 해결하기 위해 TypeScript가 기본으로 제공하는 타입 변환 도구입니다.


1. Partial<T> — 모든 프로퍼티를 선택적으로

타입의 모든 프로퍼티를 optional(?)로 바꿉니다. 폼 수정, PATCH API 요청 타입에 자주 쓰입니다.

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

// User의 모든 필드가 선택적이 됩니다
type UpdateUserDto = Partial<User>
// { id?: number; name?: string; email?: string }

// 실사용 예시: PATCH 요청
async function updateUser(id: number, data: Partial<User>) {
  await fetch(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(data),
  })
}

updateUser(1, { name: '우연' })          // ✅ 일부만 전달해도 OK
updateUser(1, { name: '우연', email: 'a@b.com' })  // ✅
Partial은 내부적으로 { [P in keyof T]?: T[P] }로 구현되어 있습니다. 깊은 중첩 객체는 얕게(shallow)만 적용되므로, 중첩된 경우 직접 재귀 타입을 만들거나 DeepPartial 유틸을 쓰는 게 좋습니다.

2. Required<T> — 모든 프로퍼티를 필수로

Partial의 반대로, 모든 optional을 제거해 필수로 만듭니다.

interface Config {
  theme?: 'light' | 'dark'
  language?: string
  timezone?: string
}

// 설정 완료 후 반드시 모든 값이 채워진 상태
type ResolvedConfig = Required<Config>
// { theme: 'light' | 'dark'; language: string; timezone: string }

function applyConfig(config: ResolvedConfig) {
  // 이 함수 안에서는 모든 필드가 반드시 존재함이 보장됨
  document.documentElement.setAttribute('data-theme', config.theme)
}

3. Pick<T, K> — 필요한 프로퍼티만 골라내기

타입에서 원하는 프로퍼티만 선택해 새 타입을 만듭니다. API 응답에서 일부 필드만 사용하는 컴포넌트에 유용합니다.

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

// 유저 목록 카드에서는 id, name만 필요
type UserCard = Pick<User, 'id' | 'name'>
// { id: number; name: string }

function UserListItem({ id, name }: UserCard) {
  return <li key={id}>{name}</li>
}

// Pick + Partial 조합: 일부 필드만 선택적으로 수정
type PartialUserName = Partial<Pick<User, 'name' | 'email'>>
// { name?: string; email?: string }

4. Omit<T, K> — 특정 프로퍼티 제외하기

Pick의 반대로, 특정 프로퍼티를 제거한 타입을 만듭니다. 민감한 정보를 제거하거나 DB 모델에서 자동생성 필드를 빼는 데 씁니다.

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

// 생성 요청 DTO: id, createdAt은 서버에서 자동 생성
type CreateUserDto = Omit<User, 'id' | 'createdAt'>
// { name: string; email: string; password: string }

// 클라이언트에 응답할 때 password 제거
type SafeUser = Omit<User, 'password'>
// { id: number; name: string; email: string; createdAt: string }
Pick은 "이것만 남길게", Omit은 "이것만 뺄게" — 필드가 많을수록 Omit이 더 간결합니다.

5. Readonly<T> — 불변 타입 만들기

모든 프로퍼티를 읽기 전용으로 만듭니다. 상태 객체나 설정값이 의도치 않게 변경되는 걸 막을 때 씁니다.

interface Point {
  x: number
  y: number
}

const origin: Readonly<Point> = { x: 0, y: 0 }

origin.x = 10  // ❌ Error: Cannot assign to 'x' because it is a read-only property

// React에서 props 타입에 Readonly를 적용하면
// 컴포넌트 내부에서 실수로 props를 수정하는 걸 컴파일 단계에서 막을 수 있습니다
type ButtonProps = Readonly<{
  label: string
  onClick: () => void
}>

6. Record<K, V> — 키-값 맵 타입 만들기

키 타입과 값 타입을 받아 객체 타입을 만듭니다. 딕셔너리 형태의 데이터를 다룰 때 편리합니다.

type Status = 'todo' | 'in-progress' | 'done'

interface Task {
  title: string
  count: number
}

// 상태별 태스크 집계 객체
const taskByStatus: Record<Status, Task> = {
  todo:        { title: '할 일', count: 5 },
  'in-progress': { title: '진행 중', count: 2 },
  done:        { title: '완료', count: 12 },
}

// API 응답을 id 기반으로 정규화할 때도 자주 쓰임
type UserMap = Record<number, User>

const usersById: UserMap = {
  1: { id: 1, name: '우연', email: 'a@b.com' },
  2: { id: 2, name: '클로드', email: 'b@c.com' },
}

7. Exclude<T, U> / Extract<T, U> — 유니온 타입 필터링

유니온 타입에서 특정 타입을 빼거나 추려낼 때 사용합니다.

type AllEvents = 'click' | 'focus' | 'blur' | 'keydown' | 'scroll'

// scroll만 제외
type WithoutScroll = Exclude<AllEvents, 'scroll'>
// 'click' | 'focus' | 'blur' | 'keydown'

// 키보드 관련만 추출
type KeyEvents = Extract<AllEvents, 'keydown' | 'keyup' | 'keypress'>
// 'keydown'  (AllEvents에 없는 keyup, keypress는 자동 제외)

// null, undefined 제거에도 자주 쓰임
type NonNullString = Exclude<string | null | undefined, null | undefined>
// string

8. ReturnType<T> — 함수 반환 타입 추출

함수의 반환 타입을 별도 선언 없이 추출합니다. 외부 라이브러리 함수나 자동생성 코드의 반환 타입을 재사용할 때 유용합니다.

function getUser() {
  return {
    id: 1,
    name: '우연',
    role: 'admin' as const,
  }
}

type UserResponse = ReturnType<typeof getUser>
// { id: number; name: string; role: 'admin' }

// TanStack Query와 조합
import { useQuery } from '@tanstack/react-query'

const useUserQuery = () =>
  useQuery({ queryKey: ['user'], queryFn: getUser })

type UserQueryResult = ReturnType<typeof useUserQuery>
// useQuery의 반환 타입 전체를 그대로 가져옴

한눈에 보는 유틸리티 타입 정리

유틸리티 타입 역할 주요 사용 상황
Partial<T>모든 필드 선택적으로PATCH DTO, 폼 수정
Required<T>모든 필드 필수로초기화 완료 후 타입 보장
Pick<T, K>필드 선택컴포넌트 props 축소
Omit<T, K>필드 제외CREATE DTO, 민감정보 제거
Readonly<T>읽기 전용상태/설정 불변 보장
Record<K, V>키-값 맵정규화 데이터, 딕셔너리
Exclude<T, U>유니온에서 제외이벤트 타입 필터링
Extract<T, U>유니온에서 추출특정 타입만 골라내기
ReturnType<T>함수 반환 타입라이브러리 타입 재사용

유틸리티 타입을 잘 조합하면 타입 선언 중복을 크게 줄일 수 있습니다. 특히 Omit + Partial, Pick + Readonly 같은 조합은 API DTO 설계나 컴포넌트 props 정의에서 거의 매일 쓰게 됩니다.

다음 글에서는 조건부 타입(Conditional Types)infer 키워드를 활용한 고급 타입 추론을 다뤄볼 예정입니다.