Waylog Blog
← 목록으로 돌아가기

TypeScript Utility Types 정복하기

TypeScript

TypeScript Utility Types

TypeScript를 사용하다 보면 비슷하지만 조금씩 다른 타입들을 매번 새로 정의해야 하는 상황을 마주합니다. API 응답에는 id가 필수지만, 데이터를 생성하는 요청 폼(Form)에서는 id가 없어야 한다거나, 특정 상태 값 중 일부만 수정하고 싶을 때 말이죠. 이때마다 interface를 복사-붙여넣기 하고 있다면, 당신은 TypeScript의 진정한 힘을 10%도 쓰지 못하고 있는 것입니다.

이 글에서는 TypeScript가 기본으로 제공하는 Utility Types를 활용하여, 중복을 제거하고(DRY), 유지보수하기 쉬우며, 더욱 안전한 타입 시스템을 구축하는 방법을 약 3,000자 분량으로 심도 있게 다룹니다.

1. 기본 변형: 모양 바꾸기 (Partial, Required, Readonly)

1.1 Partial<T>: 모든 것을 선택적으로

가장 흔하게 쓰이는 유틸리티 타입입니다. 기존 타입의 모든 속성을 선택적(Optional, ?)으로 만듭니다. 주로 PATCH 요청(일부 수정)을 처리할 때 유용합니다.

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

// ❌ 나쁜 예: 수정용 타입을 별도로 만듦
interface UpdateUserDto {
  name?: string;
  email?: string;
}

// ✅ 좋은 예: Partial 사용
function updateUser(id: number, fieldsToUpdate: Partial<User>) {
  // ...
}

updateUser(1, { email: 'new@example.com' }); // name이 없어도 OK

1.2 Required<T> & Readonly<T>

  • Required<T>: 반대로 모든 속성을 필수(Required)로 만듭니다. 설정 객체에서 선택적 옵션을 받아 내부 로직에서는 기본값을 채워 필수로 다룰 때 유용합니다.
  • Readonly<T>: 모든 속성을 읽기 전용으로 만듭니다. Redux나 Zustand 같은 상태 관리 라이브러리에서 **불변성(Immutability)**을 강제하고 싶을 때 탁월합니다.

2. 선택과 배제: Pick과 Omit

2.1 Pick<T, K>: 필요한 것만 골라내기

거대한 인터페이스에서 당장 필요한 몇 가지 필드만 가져옵니다.

// User에서 id와 email만으로 이루어진 새 타입을 만듦
type UserSummary = Pick<User, 'id' | 'email'>;

2.2 Omit<T, K>: 불필요한 것 빼기

Pick의 반대입니다. 특정 필드만 제거하고 나머지(Rest)를 남깁니다.

// User에서 민감한 password 필드를 제거
type UserWithoutPassword = Omit<User, 'password'>;

💡 Tip: Omit은 유니온 타입과 결합될 때 더욱 강력해집니다.

3. 매핑과 조건부 타입: Record, Exclude, Extract

3.1 Record<K, T>: 객체 타입 생성기

특정한 키 집합에 대해 동일한 값 타입을 가지는 객체를 만들 때, 인덱스 시그니처({ [key: string]: ... })보다 훨씬 명시적이고 안전합니다.

type Page = 'home' | 'about' | 'contact';

const navTitles: Record<Page, string> = {
  home: '메인',
  about: '소개',
  contact: '문의', // 하나라도 빠뜨리면 에러 발생!
};

3.2 Exclude<T, U> & Extract<T, U>

유니온 타입(|)을 다룰 때 사용합니다.

  • Exclude: T에서 U에 할당 가능한 타입을 제거합니다.
  • Extract: T에서 U에 할당 가능한 타입만 추출합니다.
type Status = 'success' | 'clientError' | 'serverError';
type ErrorStatus = Exclude<Status, 'success'>; // 'clientError' | 'serverError'

4. 함수 관련 유틸리티: ReturnType, Parameters

라이브러리를 사용할 때, 라이브러리가 타입을 export 해주지 않아서 난감했던 적이 있나요? 이때 유용한 것이 타입 추론 헬퍼입니다.

4.1 ReturnType<T>

함수의 반환 타입을 추출합니다. Redux의 Action Creator나 React Query의 fetcher 함수 결과값을 타입으로 쓸 때 유용합니다.

function getUser() {
  return { id: 1, name: 'Kim', role: 'ADMIN' };
}

// getUser 함수의 반환값을 타입으로 정의
type UserResponse = ReturnType<typeof getUser>; 
// { id: number; name: string; role: string; }

4.2 Parameters<T>

함수의 파라미터 타입을 튜플로 추출합니다.

5. 마치며: 제네릭의 마법사가 되자

이 외에도 NonNullable, Awaited(Promise 처리에 필수!) 등 수많은 유틸리티 타입이 있습니다. 이들은 마법이 아니라 TypeScript의 **제네릭(Generics)**과 **맵드 타입(Mapped Types)**으로 구현된 별칭일 뿐입니다.
유틸리티 타입에 익숙해지면, 단순히 타입을 '선언'하는 것을 넘어 타입을 '계산'하고 '조립'하는 단계로 나아갈 수 있습니다. 이것이 바로 코드 베이스가 커져도 유지보수성을 잃지 않는 시니어 개발자의 비결입니다.

6. 실전 고급 유틸리티: Record, Extract, Exclude

6.1 Record<Keys, Type>

Record는 키와 값의 타입을 지정하여 객체를 생성할 때 사용합니다. API 응답의 상태 코드별 메시지를 정의하거나, 열거형 키에 대한 구조화된 매핑을 만들 때 매우 유용합니다.

6.2 Extract와 Exclude: 유니온 타입 필터링

Extract<T, U>는 T에서 U에 할당 가능한 타입만 추출하고, Exclude<T, U>는 반대로 제거합니다. 이벤트 시스템에서 특정 이벤트 타입만 선별할 때 유용합니다.

7. 조건부 타입(Conditional Types)과 추론(Infer)

조건부 타입은 삼항 연산자와 유사한 문법(T extends U ? X : Y)으로 타입 레벨의 분기를 수행합니다. infer 키워드를 사용하면 조건부 타입 내에서 타입을 추론하여 변수에 할당할 수 있습니다. Promise 내부 타입을 추출하거나 배열의 요소 타입을 추론하는 등 다양한 메타 프로그래밍이 가능합니다.

X. 깊게 파헤치는 심화 맵드 타입(Mapped Types)과 템플릿 리터럴 타입 (Deep Dive)

TypeScript의 타입 시스템을 진정한 튜링 완전성에 가깝게 밀어붙이는 요소는 바로 매핑된 타입(Mapped Types)과 조건부 타입, 그리고 템플릿 리터럴(Template Literal)의 결합입니다. 단순한 유틸리티를 넘어서, 라이브러리를 직접 설계하는 수준의 고급 패턴을 감상해 보십시오.

1. 상태 전이(State Transition) 머신을 타입으로 구체화하기

복잡한 컴포넌트를 설계할 때, "Loading", "Success", "Error" 상태를 단순히 문자열 유니온으로 두는 것보다 강력한 것은 무엇일까요?
바로 각 상태 문자열(Key)을 기반으로 이벤트 핸들러 프로퍼티를 동적으로 생성해 내는 타입 매핑 기술입니다.

type ActionPayload = {
  success: { data: string };
  error: { reason: string };
  loading: undefined;
};

// 템플릿 리터럴을 통해 `onSuccess`, `onError`, `onLoading` 인터페이스를 자동 생성!
type ActionHandlers<T> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (payload: T[K]) => void;
};

이 코드를 작성하는 순간, IDE(VS Code)는 놀라울 정도로 정교하게 프로퍼티를 자동 완성(AutoComplete) 해줍니다. 만약 error라는 키를 failure로 이름을 바꾸면 전체 파일의 인터페이스가 알아서 에러를 뿜으며 컴파일 타임에 버그를 차단합니다. 이것은 단순한 타입 확인이 아니라 "코드 내의 계약(Contract)을 컴파일러가 입증"하는 거대한 패러다임 시프트입니다.

2. Infer와 분배적 조건부 타입 (Distributive Conditional Types)

ReturnType<T>이나 Parameters<T> 유틸리티 타입의 내부를 열어보면 infer 키워드가 핵심 역할을 합니다.
infer는 제네릭 안의 특정 요소(예: Promise의 반환값 T)의 타입을 "컴파일러, 미안하지만 여기 뭐가 들어올지 네가 추론해줘"라고 강제하는 마법의 키워드입니다. 대규모 비동기 API 통신을 다루는 클라이언트 SDK를 직접 개발할 때 infer를 자유자재로 다루지 못하면, 결국 any나 무수히 긴 캐스팅 지옥에 빠지고 맙니다.
타입스크립트는 단순한 자바스크립트의 주석이 아닙니다. 이 고급 기능들을 마스터하는 순간, 타입은 "코드가 실행되기 전 모든 버그를 걸러내는 두 번째 런타임"이 됩니다.

X. 깊게 파헤치는 심화 맵드 타입(Mapped Types)과 템플릿 리터럴 타입 (Deep Dive)

TypeScript의 타입 시스템을 진정한 튜링 완전성에 가깝게 밀어붙이는 요소는 바로 매핑된 타입(Mapped Types)과 조건부 타입, 그리고 템플릿 리터럴(Template Literal)의 결합입니다. 단순한 유틸리티를 넘어서, 라이브러리를 직접 설계하는 수준의 고급 패턴을 감상해 보십시오.

1. 상태 전이(State Transition) 머신을 타입으로 구체화하기

복잡한 컴포넌트를 설계할 때, "Loading", "Success", "Error" 상태를 단순히 문자열 유니온으로 두는 것보다 강력한 것은 무엇일까요?
바로 각 상태 문자열(Key)을 기반으로 이벤트 핸들러 프로퍼티를 동적으로 생성해 내는 타입 매핑 기술입니다.

type ActionPayload = {
  success: { data: string };
  error: { reason: string };
  loading: undefined;
};

// 템플릿 리터럴을 통해 `onSuccess`, `onError`, `onLoading` 인터페이스를 자동 생성!
type ActionHandlers<T> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (payload: T[K]) => void;
};

이 코드를 작성하는 순간, IDE(VS Code)는 놀라울 정도로 정교하게 프로퍼티를 자동 완성(AutoComplete) 해줍니다. 만약 error라는 키를 failure로 이름을 바꾸면 전체 파일의 인터페이스가 알아서 에러를 뿜으며 컴파일 타임에 버그를 차단합니다. 이것은 단순한 타입 확인이 아니라 "코드 내의 계약(Contract)을 컴파일러가 입증"하는 거대한 패러다임 시프트입니다.

2. Infer와 분배적 조건부 타입 (Distributive Conditional Types)

ReturnType<T>이나 Parameters<T> 유틸리티 타입의 내부를 열어보면 infer 키워드가 핵심 역할을 합니다.
infer는 제네릭 안의 특정 요소(예: Promise의 반환값 T)의 타입을 "컴파일러, 미안하지만 여기 뭐가 들어올지 네가 추론해줘"라고 강제하는 마법의 키워드입니다. 대규모 비동기 API 통신을 다루는 클라이언트 SDK를 직접 개발할 때 infer를 자유자재로 다루지 못하면, 결국 any나 무수히 긴 캐스팅 지옥에 빠지고 맙니다.
타입스크립트는 단순한 자바스크립트의 주석이 아닙니다. 이 고급 기능들을 마스터하는 순간, 타입은 "코드가 실행되기 전 모든 버그를 걸러내는 두 번째 런타임"이 됩니다.

X. 깊게 파헤치는 심화 맵드 타입(Mapped Types)과 템플릿 리터럴 타입 (Deep Dive)

TypeScript의 타입 시스템을 진정한 튜링 완전성에 가깝게 밀어붙이는 요소는 바로 매핑된 타입(Mapped Types)과 조건부 타입, 그리고 템플릿 리터럴(Template Literal)의 결합입니다. 단순한 유틸리티를 넘어서, 라이브러리를 직접 설계하는 수준의 고급 패턴을 감상해 보십시오.

1. 상태 전이(State Transition) 머신을 타입으로 구체화하기

복잡한 컴포넌트를 설계할 때, "Loading", "Success", "Error" 상태를 단순히 문자열 유니온으로 두는 것보다 강력한 것은 무엇일까요?
바로 각 상태 문자열(Key)을 기반으로 이벤트 핸들러 프로퍼티를 동적으로 생성해 내는 타입 매핑 기술입니다.

type ActionPayload = {
  success: { data: string };
  error: { reason: string };
  loading: undefined;
};

// 템플릿 리터럴을 통해 `onSuccess`, `onError`, `onLoading` 인터페이스를 자동 생성!
type ActionHandlers<T> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (payload: T[K]) => void;
};

이 코드를 작성하는 순간, IDE(VS Code)는 놀라울 정도로 정교하게 프로퍼티를 자동 완성(AutoComplete) 해줍니다. 만약 error라는 키를 failure로 이름을 바꾸면 전체 파일의 인터페이스가 알아서 에러를 뿜으며 컴파일 타임에 버그를 차단합니다. 이것은 단순한 타입 확인이 아니라 "코드 내의 계약(Contract)을 컴파일러가 입증"하는 거대한 패러다임 시프트입니다.

2. Infer와 분배적 조건부 타입 (Distributive Conditional Types)

ReturnType<T>이나 Parameters<T> 유틸리티 타입의 내부를 열어보면 infer 키워드가 핵심 역할을 합니다.
infer는 제네릭 안의 특정 요소(예: Promise의 반환값 T)의 타입을 "컴파일러, 미안하지만 여기 뭐가 들어올지 네가 추론해줘"라고 강제하는 마법의 키워드입니다. 대규모 비동기 API 통신을 다루는 클라이언트 SDK를 직접 개발할 때 infer를 자유자재로 다루지 못하면, 결국 any나 무수히 긴 캐스팅 지옥에 빠지고 맙