본문으로 건너뛰기

TypeScript Utility Types 정복하기

TypeScript31분 읽기본문 15,357

핵심 요약

이 글에서 확인할 내용

TypeScript를 사용하다 보면 비슷하지만 조금씩 다른 타입들을 매번 새로 정의해야 하는 상황을 마주합니다. API 응답에는 id 가 필수지만, 데이터를 생성하는 요청 폼(Form)에서는 id 가 없어야 한다거나, 특정 상태 값 중 일부만 수정하고 싶을 때 말이죠.

  • 1. 기본 변형: 모양 바꾸기 (Partial, Required, Readonly)
  • 2. 선택과 배제: Pick과 Omit
  • 3. 매핑과 조건부 타입: Record, Exclude, Extract

TypeScript Utility Types

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

이 글에서는 TypeScript가 기본으로 제공하는 Utility Types를 활용하여, 중복을 제거하고(DRY), 유지보수하기 쉬우며, 더욱 안전한 타입 시스템을 구축하는 방법을 심도 있게 다룹니다. 단순히 "이 유틸리티는 이렇게 쓴다"는 사용법 나열에서 그치지 않고, 우리는 각 유틸리티가 lib.es5.d.ts를 비롯한 표준 라이브러리 안에서 어떤 매핑 타입과 조건부 타입으로 구현되어 있는지까지 열어볼 것입니다. 내부 구현을 이해하면, 표준 유틸리티가 예상 밖으로 동작하는 함정을 미리 피할 수 있고, 필요할 때 직접 커스텀 유틸리티를 조립할 수 있게 됩니다. 유틸리티 타입은 결코 컴파일러가 숨겨둔 마법이 아니라, 우리가 매일 쓰는 제네릭·매핑 타입·조건부 타입 문법으로 쓰인 짧은 별칭일 뿐이라는 사실을 이 글이 끝날 때쯤에는 몸으로 이해하게 될 것입니다.

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

이 편리함의 정체를 알려면 Partial의 실제 정의를 봐야 합니다. 표준 라이브러리에서 Partial은 다음과 같은 매핑 타입(Mapped Type) 한 줄로 구현되어 있습니다.

type Partial<T> = {
  [P in keyof T]?: T[P];
};

여기서 벌어지는 일을 우리 관점에서 풀어보면 이렇습니다. keyof TT가 가진 모든 프로퍼티 키를 유니온으로 뽑아냅니다. User라면 'id' | 'name' | 'email'이 됩니다. [P in keyof T]는 그 유니온을 하나씩 순회하며 각 키에 대해 새 프로퍼티를 만들고, 뒤에 붙은 ?선택적 수정자(optional modifier)를 각 프로퍼티에 추가합니다. 값 타입은 T[P], 즉 원래 프로퍼티가 가지고 있던 타입을 그대로 인덱스 접근으로 가져옵니다. 이렇게 원본 타입의 키를 그대로 순회하는 매핑 타입을 동형 매핑 타입(homomorphic mapped type)이라 부르는데, 동형 매핑 타입은 원본이 가진 readonly·? 수정자를 보존하고, 배열·튜플에 적용하면 배열·튜플 구조를 유지한다는 중요한 성질이 있습니다. 그래서 Partial<[string, number]>[string?, number?]가 되고, 우리가 기대한 대로 동작합니다.

문제는 이 편리함이 곧 함정이 되기도 한다는 점입니다. 방금 예시에서 Partial<User>id까지 선택적으로 만들어 버립니다. 실무의 수정 API에서 id는 경로 파라미터로 받고 바디에서는 절대 바꾸면 안 되는 값인데, Partial<User>를 그대로 바디 타입으로 쓰면 id를 담아 보내도 컴파일러가 통과시킵니다. 즉 Partial은 "모든 필드를 없어도 되게" 만들 뿐, "바꿔서는 안 되는 필드를 막아주지는" 않습니다. 이럴 때는 Partial<Omit<User, 'id'>>처럼 조합하거나, 뒤에서 다룰 커스텀 유틸리티로 "수정 가능한 필드만" 정확히 표현해야 합니다. 편리함에 기대어 도메인 규칙을 타입에서 흘려버리는 것은 유틸리티 타입 남용의 대표적인 첫걸음입니다.

1.2 Required<T> & Readonly<T>

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

이 둘의 구현도 Partial과 대칭을 이룹니다.

type Required<T> = {
  [P in keyof T]-?: T[P];
};

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

Required에서 눈여겨볼 것은 -?입니다. 매핑 타입에서는 수정자 앞에 -를 붙여 수정자를 제거할 수 있습니다. -?는 선택적 수정자를 벗겨 필수로 만들고, -readonly는 읽기 전용을 벗겨 다시 쓰기 가능하게 만듭니다. 반대로 +?, +readonly처럼 명시적으로 추가할 수도 있지만, 아무 부호도 없으면 +가 기본입니다. 이 문법을 알면 "옵션 객체를 받아 기본값을 채운 뒤 내부에서는 모든 필드가 채워졌음을 보장하는" 설정 병합 패턴을 타입으로 정확히 표현할 수 있습니다.

여기서 실무자가 반드시 기억할 함정 두 가지가 있습니다. 첫째, Readonly<T>얕은(shallow) 불변성만 제공합니다. Readonly<User>user.name = ... 재할당은 막지만, 프로퍼티가 객체나 배열이면 그 내부까지 얼려주지는 않습니다. Readonly<{ tags: string[] }>에서 obj.tags.push('x')는 여전히 컴파일을 통과합니다. 깊은 불변성이 필요하면 뒤에서 만들 DeepReadonly 같은 재귀 유틸리티가 필요합니다. 둘째, RequiredPartial한 겹만 변형합니다. 중첩된 설정 객체를 통째로 필수화하고 싶다고 Required<Config>를 걸어도 최상위 프로퍼티만 필수가 되고 하위 객체의 선택성은 그대로 남습니다. 유틸리티 타입은 기본적으로 한 층만 건드린다는 사실을 놓치면, 타입은 안전해 보이는데 런타임에서는 undefined가 새어 나오는 상황을 만들게 됩니다.

2. 선택과 배제: Pick과 Omit

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

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

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

Pick의 구현은 매핑 타입의 순회 대상을 keyof T가 아니라 우리가 지정한 키 집합 K로 바꾼 형태입니다.

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

K extends keyof T라는 제약이 핵심입니다. 이 제약 덕분에 존재하지 않는 키를 Pick하려 하면 컴파일 단계에서 바로 걸립니다. Pick<User, 'age'>Userage가 없으므로 에러입니다. 즉 Pick은 단순히 필드를 고르는 것을 넘어, "이 타입에 실제로 존재하는 필드만 고른다"는 계약을 컴파일러가 보증해 줍니다. 그리고 Pick 역시 동형 매핑 타입으로 취급되기 때문에 원본의 readonly·? 수정자를 보존합니다. readonly name?: string이었던 필드를 Pick하면 여전히 readonly이자 선택적입니다.

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

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

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

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

그런데 Omit은 앞의 유틸리티들과 결이 조금 다릅니다. Partial·Pick이 직접 매핑 타입인 반면, Omit은 두 유틸리티를 조합해서 만든 합성 유틸리티입니다.

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

읽어 보면 이렇습니다. keyof T로 모든 키를 뽑고, 그중 제거 대상 K에 해당하는 키를 Exclude로 걸러낸 뒤, 남은 키들만 Pick으로 다시 골라냅니다. 흥미로운 점은 제거할 키의 제약이 K extends keyof T가 아니라 K extends keyof any라는 것입니다. 즉 Omit은 원본에 없는 키를 지워달라고 해도 에러를 내지 않습니다. Omit<User, 'nonexistent'>는 그냥 User를 통째로 돌려줍니다. 오타를 잡아주지 못한다는 뜻이므로, 리팩터링 과정에서 필드 이름을 바꿨는데 Omit이 조용히 지나가 버리는 상황을 조심해야 합니다.

더 큰 함정은 Omit유니온 타입에 분배되지 않는다는 점입니다. A | B 형태의 판별 유니온(discriminated union)에 Omit을 걸면, 우리가 기대하는 "각 멤버에서 필드를 제거한 유니온"이 아니라 "두 멤버의 공통 키만 남긴 하나의 타입"이 나옵니다. 그 이유는 keyof (A | B)가 각 멤버 키의 교집합으로 계산되기 때문입니다. 그래서 판별자 필드까지 뭉개져 판별 유니온이 무너지는 사고가 자주 납니다. 이 문제를 피하려면 조건부 타입의 분배 성질을 이용한 커스텀 DistributiveOmit을 써야 하며, 그 구현은 뒤의 커스텀 유틸리티 절에서 다룹니다. Omit은 매일 쓰는 도구지만, 유니온 앞에서는 반드시 한 번 멈춰 서서 결과 타입을 IDE 호버로 확인하는 습관이 필요합니다.

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

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

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

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

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

Record의 구현은 이렇습니다.

type Record<K extends keyof any, T> = {
  [P in K]: T;
};

인덱스 시그니처와 결정적으로 다른 지점이 여기 있습니다. 인덱스 시그니처 { [key: string]: string }는 "어떤 문자열 키든 올 수 있다"는 개방형 선언이라 오타를 잡지 못하고, 존재하지 않는 키를 조회해도 string을 반환한다고 컴파일러가 거짓말을 합니다. 반면 Record<Page, string>은 키 집합이 'home' | 'about' | 'contact'닫혀 있으므로, 하나라도 빠뜨리면 에러가 나고 오타 키를 넣어도 에러가 납니다. 상태 코드별 메시지 매핑, 라우트별 설정, 이벤트 이름별 핸들러처럼 "가능한 키의 목록이 명확히 정해진" 곳에서는 인덱스 시그니처 대신 Record를 쓰는 것이 원칙입니다. 다만 Record는 동형 매핑 타입이 아니므로 원본 수정자를 보존하는 성질은 없습니다. 순수하게 새 객체 골격을 찍어내는 틀에 가깝습니다.

Record가 실무에서 특히 빛나는 순간은 "빠뜨림 방지(exhaustiveness)"가 필요할 때입니다. 예를 들어 결제 상태가 pending | paid | failed | refunded 네 가지라면, 상태별 화면 문구를 Record<PaymentStatus, string>으로 선언해 두는 순간, 나중에 상태가 하나 추가됐는데 문구 매핑을 갱신하지 않으면 컴파일이 깨집니다. 타입이 "이 목록을 빠짐없이 처리하라"는 체크리스트 역할을 대신해 주는 것입니다. 여기에 TypeScript 4.9의 satisfies 연산자를 곁들이면, 매핑 객체가 Record 제약을 만족하는지 검증하면서도 각 값의 구체적인 리터럴 타입을 잃지 않을 수 있어 더욱 정교해집니다.

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'

이 둘은 매핑 타입이 아니라 조건부 타입(Conditional Type)으로 구현됩니다.

type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;

문법은 삼항 연산자와 똑같지만, 여기에 TypeScript의 가장 중요하고도 헷갈리는 규칙이 숨어 있습니다. 바로 분배 조건부 타입(distributive conditional type)입니다. T가 벌거벗은(naked) 타입 파라미터이고 유니온일 때, T extends U ? X : Y는 유니온의 각 멤버에 조건을 개별적으로 적용한 뒤 그 결과를 다시 유니온으로 합칩니다. 그래서 Exclude<'success' | 'clientError' | 'serverError', 'success'>('success' extends 'success' ? never : 'success') | (...) | (...)로 펼쳐지고, never | 'clientError' | 'serverError'가 되어 최종적으로 'clientError' | 'serverError'가 남습니다. never는 유니온에서 흡수되어 사라지기 때문입니다. ExcludeExtract가 유니온 필터처럼 우아하게 동작하는 비결이 바로 이 분배 성질입니다. 이 성질을 제대로 이해하지 못하면 다음 절과 조건부 타입 절에서 다룰 예측 불가한 결과에 계속 발목을 잡히게 됩니다.

판별 유니온에서 특정 종류의 멤버만 뽑아내는 패턴이 Extract의 대표적인 실무 활용입니다. 예컨대 type Action = { type: 'add'; payload: number } | { type: 'reset' } | { type: 'remove'; id: string }가 있을 때, Extract<Action, { type: 'add' }>add 액션 하나만 정확히 골라냅니다. 리듀서에서 액션 타입을 좁혀 다룰 때, 그리고 이벤트 버스나 메시지 큐의 스키마를 타입으로 관리할 때 특히 유용합니다.

한 가지 더, 자주 쓰는 NonNullable도 이 계열입니다. 과거에는 조건부 타입으로 정의됐지만 TypeScript 4.8부터는 교차 타입을 이용한 type NonNullable<T> = T & {}로 바뀌었습니다. nullundefined{}(빈 객체 타입)에 할당될 수 없으므로 교차하면 사라지고, 나머지 타입은 그대로 남는 원리입니다. 표준 유틸리티의 구현이 버전에 따라 조용히 개선된다는 것을 보여주는 좋은 사례이며, 값을 좁히는 로직을 다룰 때는 TypeScript 에러 처리 설계: try/catch, Result 패턴, 도메인 오류를 구분하는 실무 기준에서 정리한 오류 타입 좁히기 패턴과 함께 보면 이해가 빠릅니다.

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; }

ReturnTypeParameters의 구현을 열어보면 조건부 타입과 infer가 어떻게 협력하는지가 한눈에 보입니다.

type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;

infer R은 "컴파일러야, 이 위치에 들어올 타입을 네가 추론해서 R이라는 이름에 담아 줘"라는 요청입니다. 함수 시그니처 패턴에 원본 타입을 매칭시키면서, 반환 위치의 타입을 R로, 매개변수 위치의 튜플을 P로 뽑아내는 것입니다. 그래서 Parameters의 결과는 배열이 아니라 튜플이며, 매개변수의 순서와 개수, 이름까지 보존됩니다.

이 두 유틸리티에는 실무에서 사람을 여러 번 당황하게 만드는 함정이 있습니다. 오버로드된 함수ReturnType이나 Parameters를 걸면 모든 시그니처를 합쳐 주는 것이 아니라 마지막 오버로드 시그니처만 골라 추론합니다. 오버로드 목록의 순서에 따라 결과가 통째로 달라지므로, 여러 시그니처를 가진 함수에서 타입을 뽑을 때는 반드시 실제 결과를 확인해야 합니다. 또한 typeof someFunction으로 함수의 타입을 꺼낼 때는 그 함수가 선언된 시점의 타입만 잡히므로, 제네릭 함수의 경우 타입 파라미터가 기본 제약으로 고정된 형태로 추론된다는 점도 알아둘 만합니다.

4.2 Parameters<T>

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

이 계열에는 ReturnType·Parameters 외에도 클래스와 생성자를 다루는 형제들이 있습니다. ConstructorParameters<T>는 생성자의 인자 튜플을, InstanceType<T>new로 만든 인스턴스 타입을 뽑아냅니다. 그리고 비동기 코드에서 없어서는 안 될 Awaited<T>가 있습니다. Awaited는 단순 조건부 타입이 아니라 재귀 조건부 타입으로, 중첩된 Promisethenable을 끝까지 벗겨 실제 결과 타입을 얻습니다.

// 표준 라이브러리의 Awaited는 대략 이런 재귀 구조를 가진다
type Awaited<T> =
  T extends null | undefined ? T :
  T extends object & { then(onfulfilled: infer F, ...args: infer _): any } ?
    F extends (value: infer V, ...args: infer _) => any ? Awaited<V> : never :
  T;

Awaited<Promise<Promise<string>>>string이 되는 것은 Awaitedthen의 콜백 인자 타입을 infer로 뽑아 자기 자신에게 다시 넘기며 재귀하기 때문입니다. async 함수의 반환 타입을 Awaited<ReturnType<typeof fetchUser>>로 조합해 얻는 패턴은 API 클라이언트 타입을 자동으로 도출하는 실무에서 아주 자주 등장합니다.

5. 유틸리티 타입은 마법이 아니라 문법이다

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

다만 여기서 멈추지 말고, 지금부터는 이 도구들을 어떻게 조합하고, 어디서 직접 만들며, 언제 멈춰야 하는지를 실무 관점에서 더 깊이 파고들어 봅시다. 도구를 아는 것과 도구를 절제하며 쓰는 것은 다른 능력이며, 후자가 팀 전체의 코드베이스 건강을 좌우합니다.

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

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

이제 앞에서 예고한 분배 조건부 타입의 함정을 본격적으로 파헤쳐 봅시다. 이 규칙은 유틸리티 타입을 직접 만들기 시작하는 순간 가장 먼저 부딪히는 벽입니다. 다음 두 타입은 겉보기엔 거의 같지만 결과가 완전히 다릅니다.

// 분배됨: T가 벌거벗은 타입 파라미터라서 유니온에 개별 적용된다
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]

// 분배 안 됨: [T]로 감싸면 유니온 전체가 하나로 취급된다
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type B = ToArrayNonDist<string | number>; // (string | number)[]

분배가 일어나는 조건은 "검사 대상 T가 조건부 타입 안에서 벌거벗은 타입 파라미터일 것"입니다. T[T]처럼 튜플로 감싸거나 다른 형태로 한 번 가공하면 분배가 꺼지고, 유니온 전체를 하나의 덩어리로 비교합니다. 두 동작 모두 필요한 상황이 있으므로, 분배를 켜고 끄는 이 기법은 커스텀 유틸리티 설계의 필수 도구입니다.

분배 조건부 타입에는 특히 악명 높은 사각지대가 하나 더 있습니다. 바로 never입니다. never는 "멤버가 하나도 없는 빈 유니온"으로 취급되기 때문에, 분배 조건부 타입에 never를 넣으면 순회할 멤버가 없어 조건식이 아예 실행되지 않고 결과도 never가 됩니다. type Wrap<T> = T extends unknown ? T[] : neverWrap<never>를 넣으면 우리가 기대한 never[]가 아니라 그냥 never가 나옵니다. 이 미묘한 동작 때문에 "빈 유니온이 흘러 들어왔을 때 조건 분기가 통째로 증발하는" 버그가 생깁니다. 이럴 때도 [T] extends [never] ? ... : ...처럼 튜플로 감싸 분배를 끄면 never를 정상적인 값처럼 검사할 수 있습니다.

infer는 조건부 타입 안에서만 쓸 수 있는 추론 도구입니다. 배열의 요소 타입을 뽑는 type ElementType<T> = T extends (infer U)[] ? U : T 같은 패턴이 기본기이고, 여기서 나아가 infer에 제약을 걸거나(infer U extends string), 여러 infer를 한 조건식에 배치해 함수 시그니처를 통째로 분해하는 고급 기법까지 뻗어 나갑니다. 이런 타입 레벨 프로그래밍을 더 넓게 다룬 내용은 TypeScript의 브레이크를 한계까지: 고급 타입 시스템과 실무 적용 패턴 완전 정복에서 이어서 확인할 수 있습니다.

본문 중간 핵심 흐름을 시각화한 TypeScript 일러스트

7. 키 리매핑과 템플릿 리터럴 타입

TypeScript의 타입 시스템을 밀어붙이는 또 하나의 축은 매핑된 타입(Mapped Types)과 템플릿 리터럴(Template Literal)의 결합입니다. 단순한 유틸리티를 넘어, 라이브러리를 직접 설계하는 수준의 패턴을 살펴봅니다.

7.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)을 컴파일러가 입증"하는 거대한 패러다임 시프트입니다.

여기서 핵심 문법은 매핑 타입의 as 절, 즉 키 리매핑(key remapping)입니다. TypeScript 4.1에서 도입된 이 기능은 [K in keyof T as NewName] 형태로 순회하면서 각 키의 이름을 새로 계산할 수 있게 해줍니다. 템플릿 리터럴 타입 `on${Capitalize<...>}`와 결합하면 successonSuccess로, erroronError로 변환됩니다. Capitalize, Uppercase, Lowercase, Uncapitalize는 표준 라이브러리에 매핑 타입으로 정의된 것이 아니라 컴파일러에 내장된(intrinsic) 문자열 조작 타입이라 우리가 소스로 열어볼 수는 없지만, 사용법은 여느 유틸리티와 다르지 않습니다. 더 나아가 as 절에서 특정 키를 never로 매핑하면 그 키를 결과에서 아예 제거할 수 있어, "함수 타입인 프로퍼티만 골라내기"나 "특정 접두사를 가진 키만 남기기" 같은 필터링 유틸리티를 직접 만들 수 있습니다.

8. 커스텀 유틸리티 타입 만들기: 조합의 실전

표준 유틸리티만으로 부족할 때가 옵니다. 그때 우리는 지금까지 본 매핑 타입·조건부 타입·infer를 조립해 도메인에 꼭 맞는 유틸리티를 직접 만듭니다. 가장 자주 필요한 것부터 살펴봅시다.

첫째, 깊은 변형입니다. PartialReadonly가 한 겹만 처리한다는 한계를 재귀로 극복합니다.

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

둘째, 앞서 예고한 분배되는 Omit입니다. 판별 유니온을 지키면서 필드를 제거하려면 조건부 타입의 분배 성질을 빌려야 합니다.

type DistributiveOmit<T, K extends keyof any> =
  T extends unknown ? Omit<T, K> : never;

T extends unknown ? ...이 유니온의 각 멤버에 Omit을 개별 적용하도록 강제하므로, A | B의 각 멤버에서 필드가 제거된 뒤 다시 유니온으로 합쳐집니다. 표준 Omit이 뭉개버리던 판별자 필드가 이렇게 하면 살아남습니다.

셋째, "여러 옵션 중 최소 하나는 필수" 같은 도메인 규칙입니다. 검색 조건에서 이메일이든 전화번호든 최소 하나는 있어야 한다는 제약을 타입으로 표현할 수 있습니다.

type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
  Omit<T, Keys> &
  { [K in Keys]-?: Required<Pick<T, K>> & Partial<Omit<T, K>> }[Keys];

이런 커스텀 유틸리티를 팀 공용 types/utils.ts에 모아두고 짧은 주석과 사용 예시를 함께 남겨두면, 반복되는 타입 변형을 한 곳에서 관리할 수 있습니다. 이 과정에서 "타입도 재사용 가능한 컴포넌트처럼 설계한다"는 감각이 생기는데, 이는 TypeScript로 배우는 GoF 디자인 패턴: 실무 중심 완벽 가이드에서 다루는 추상화 원칙과 같은 맥락에 있습니다. 하나 덧붙이면, TypeScript 5.4에 추가된 NoInfer<T>도 알아둘 만합니다. 이는 특정 위치에서 타입 파라미터의 추론을 막아, 원치 않는 자리에서 타입이 넓게 잡히는 문제를 방지하는 최신 표준 유틸리티입니다.

9. 깊은 Partial과 재귀 유틸리티의 함정

재귀 유틸리티를 만들 때 가장 흔히 밟는 지뢰가 DeepPartial입니다. 순진하게 작성하면 다음과 같습니다.

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

문제는 T[P] extends object라는 조건이 우리가 생각하는 "평범한 객체"보다 훨씬 넓다는 데 있습니다. JavaScript에서 함수도, 배열도, Date·Map·Set·RegExp도 전부 object입니다. 그래서 위 정의는 함수 타입 프로퍼티에까지 재귀를 시도하고, Date{ getTime?: ...; ... } 같은 이상한 부분 타입으로 망가뜨리며, 배열을 인덱스별로 선택적으로 만들어 버립니다. 실무에서 쓰려면 배열은 요소에 재귀하되 배열 구조를 유지하도록, 함수와 원시 래퍼는 그대로 통과시키도록 예외 분기를 촘촘히 넣어야 합니다. "깊은 Partial 하나쯤이야" 하고 가볍게 붙였다가, 폼 상태나 설정 병합 로직 전체의 타입이 미묘하게 어긋나는 사고가 여기서 시작됩니다.

또 하나, 재귀 유틸리티는 불가능한 상태를 표현 가능하게 만들기도 합니다. DeepPartial을 API 응답 타입에 무분별하게 씌우면, 실제로는 항상 함께 존재해야 하는 필드들이 각자 undefined가 될 수 있게 되어, 이후 코드 어디서나 옵셔널 체이닝과 방어 코드를 강요받습니다. "모두 선택적"이라는 편의가 결국 "아무것도 보장되지 않는" 타입으로 귀결되는 셈입니다. 재귀 변형은 반드시 필요한 경계(폼 초안, 부분 업데이트 입력 등)에만 국소적으로 적용하고, 도메인의 핵심 타입은 가능한 한 정확하게 유지하는 편이 안전합니다.

10. 성능과 컴파일러 한계

유틸리티 타입은 컴파일 시점에 계산되므로, 지나치게 복잡한 타입은 곧 컴파일러의 부담이 됩니다. 깊이 중첩된 조건부 타입, 거대한 유니온에 걸린 분배 조건부 타입, 재귀 유틸리티의 조합은 타입 검사 시간을 눈에 띄게 늘리고, 심하면 Type instantiation is excessively deep and possibly infinite(에러 2589)라는 메시지와 함께 컴파일이 멈춥니다. TypeScript는 조건부 타입의 꼬리 재귀를 어느 정도 최적화해 상당히 깊은 재귀까지 허용하지만, 무한에 가까운 타입 계산은 여전히 한계가 있습니다.

실무 규모의 프로젝트에서 IDE 반응이 느려지고 타입 검사가 굼떠졌다면, 최근에 추가한 복잡한 제네릭 유틸리티를 의심해 볼 만합니다. tsc --generateTrace로 타입 검사 병목을 프로파일링하면 어떤 타입 인스턴스화가 시간을 잡아먹는지 추적할 수 있습니다. 화려한 타입 마법이 개발 경험 전체를 느리게 만든다면, 그 타입은 아무리 정교해도 좋은 타입이 아닙니다. 때로는 몇 줄의 명시적 타입 선언이 한 줄의 난해한 유틸리티보다 팀에 훨씬 이롭습니다.

11. 언제 유틸리티 타입을 쓰지 말아야 하는가

유틸리티 타입은 강력하지만, 남용하면 코드가 오히려 읽기 어려워집니다. 우리가 현장에서 세워둔 절제의 기준은 다음과 같습니다.

첫째, 에러 메시지와 IDE 호버가 이해 불가능해질 때 멈춥니다. Omit<Partial<Pick<...>>>처럼 유틸리티를 서너 겹 겹쳐 놓으면, 타입이 틀렸을 때 컴파일러가 뱉는 메시지가 암호문이 됩니다. 타입의 목적은 안전만이 아니라 소통입니다. 동료가 호버해서 5초 안에 의도를 파악하지 못하는 타입이라면, 차라리 이름을 붙인 명시적 인터페이스가 낫습니다.

둘째, 도메인 규칙을 흐리게 만들 때 멈춥니다. 앞서 본 Partial<User>를 수정 DTO로 쓰는 사례처럼, 편의를 위한 변형이 "바꾸면 안 되는 것"을 허용해 버린다면 안전장치가 아니라 구멍입니다. 이럴 때는 유틸리티로 파생하지 말고, 그 API의 계약을 있는 그대로 표현하는 전용 타입을 정의하는 편이 정확합니다.

셋째, 파생 관계가 오히려 결합도를 높일 때 멈춥니다. type B = Omit<A, ...>로 B를 A에서 파생하면, A가 바뀔 때 B가 의도치 않게 함께 흔들립니다. 두 타입이 우연히 비슷할 뿐 서로 다른 이유로 존재한다면, 파생은 잘못된 결합을 만듭니다. "지금 모양이 같다"와 "항상 같아야 한다"는 다른 이야기이며, 후자일 때만 파생이 정당합니다.

넷째, 타입 곡예 자체가 목적이 될 때 멈춥니다. 타입 레벨에서 거의 모든 것을 계산할 수 있다는 사실이, 모든 것을 타입으로 계산해야 한다는 뜻은 아닙니다. 런타임 검증(스키마 검증 라이브러리 등)이 더 적합한 문제를 억지로 타입 시스템에 밀어 넣으면, 유지보수 불가능한 코드가 됩니다.

자주 묻는 질문 (FAQ)

Q. interface와 유틸리티 타입으로 만든 type, 무엇을 언제 쓰나요?
공개 API의 객체 모양은 확장(선언 병합)과 가독성 면에서 interface가 유리한 경우가 많고, 기존 타입에서 파생하거나 유니온·조건부 계산이 필요한 순간에는 type 별칭이 필수입니다. 유틸리티 타입은 대부분 type으로 소비되므로, "선언은 interface, 변형과 계산은 type"이라는 느슨한 기준이 실무에서 잘 작동합니다.

Q. Omit을 판별 유니온에 걸었더니 타입이 이상해졌습니다.
표준 Omit은 유니온에 분배되지 않고 공통 키만 남기기 때문입니다. 위의 DistributiveOmit을 사용하면 각 멤버에 개별 적용되어 판별자 필드가 보존됩니다.

Q. Exclude<T, never>Extract<T, never>가 예상과 다르게 나옵니다.
분배 조건부 타입에 never가 들어가면 순회할 멤버가 없어 결과가 never가 되기 때문입니다. never를 정상 값처럼 검사하려면 [T] extends [never]로 감싸 분배를 끄세요.

Q. ReturnType이 오버로드 함수에서 엉뚱한 타입을 줍니다.
ReturnTypeParameters는 오버로드된 함수의 마지막 시그니처만 사용합니다. 필요한 시그니처가 마지막에 오도록 선언 순서를 조정하거나, 해당 시그니처를 별도 함수 타입으로 분리해 추출하세요.

운영 체크리스트

  • 수정용 DTO에 Partial<T>를 씌울 때, 바꾸면 안 되는 키(id, 생성일 등)를 먼저 Omit으로 제거했는가.
  • Omit을 유니온에 걸었을 때 판별자 필드가 사라지지 않는지 IDE 호버로 확인했는가.
  • Readonly·Partial·Required가 한 겹만 적용된다는 점을 인지하고, 깊은 변형이 필요한 곳에는 재귀 유틸리티를 명시적으로 사용했는가.
  • 커스텀 재귀 유틸리티에서 배열·함수·Date 같은 특수 객체 예외를 처리했는가.
  • 복잡한 유틸리티 조합이 컴파일 시간과 IDE 반응성을 해치지 않는지 점검했는가.
  • 파생 타입(type B = Omit<A>)이 A와 반드시 함께 바뀌어야 하는 관계인지, 아니면 우연히 비슷할 뿐인지 판단했는가.
  • 타입 계산으로 표현한 규칙이 런타임 검증으로 옮기는 편이 나은 문제는 아닌지 되물었는가.

유틸리티 타입은 마법이 아니라 문법입니다. 매핑 타입은 키를 순회하며 수정자를 다루고, 조건부 타입은 유니온에 분배되며 infer로 타입을 뽑아냅니다. 이 몇 가지 원리만 손에 익으면, 표준 유틸리티를 정확히 이해하는 것을 넘어 우리 도메인에 꼭 맞는 타입을 계산하고 조립하며, 동시에 언제 멈춰야 하는지도 판단할 수 있게 됩니다. 그 균형 감각이야말로 코드베이스가 커져도 타입 시스템이 짐이 아니라 자산으로 남게 하는 열쇠입니다.


같은 주제를 다른 각도로 다룬 글