타입스크립트는 자바스크립트에 정적 타입 시스템을 더한 언어로, 코드의 안정성과 가독성을 높여주는 다양한 기능을 제공합니다. 그 중에서도 ‘Partial’, ‘Required’, ‘Readonly’, ‘Pick’, ‘Omit’ 같은 내장 유틸리티 타입은 일상적인 개발에서 자주 활용되는 도구입니다.
그런데 프로젝트가 복잡해지면 한 가지 의문이 생깁니다. 내장 유틸리티 타입만으로 모든 상황을 해결할 수 있을까요? 실제로 그렇지 않은 경우가 많습니다. API 응답에서 값의 타입이 ‘string’인 프로퍼티만 골라야 하거나, 일부 필드는 그대로 두고 나머지나 옵셔널로 만들고 싶은 상황이 자주 발생합니다. 이런 순간에 직접 유틸리티 타입을 만들 줄 모르면, 같은 타입을 여러 번 반복해 정의하거나 ‘any’로 우회하게 되고, 결과적으로 타입 안정성이 무너지는 코드가 쌓이기 쉽습니다.
이러한 상황에서 진가를 발휘하는 도구가 바로 “Mapped Type”과 “Conditional Type”입니다. 이 두 가지는 내장 유틸리티 타입을 구성하는 핵심 재료이기도 합니다. 이번 글에서는 두 도구의 동작 원리를 살펴보고, 이를 토대로 나만의 유틸리티 타입을 만드는 방법까지 함께 알아보겠습니다.
미리 요점만 콕 집어보면?
Mapped Type과 Conditional Type을 이해하려면 먼저 내장 유틸리티 타입 몇 가지를 살펴볼 필요가 있습니다. 이들은 모두 위 두 가지 도구로 만들어졌기 때문입니다.
Readonly<T>는 객체 타입 T의 모든 프로퍼티를 읽기 전용으로 만들어주는 유틸리티 타입입니다. 즉, 한 번 할당된 값을 변경할 수 없도록 잠그는 역할을 합니다.
interface User {
id: number;
name: string;
}
const user: Readonly<User> = {
id: 1,
name: "kim",
};
user.name = "park";
// 오류: 읽기 전용 프로퍼티이므로 'name'에 할당할 수 없습니다.
이 덕분에 불변성을 보장해야 하는 상태 객체나 설정값을 다룰 때 유용하게 활용할 수 있습니다.
Exclude<T, U>는 유니온 타입 T에서 U에 해당하는 멤버를 제거합니다. 반대로 Extract<T, U>는 T에서 U와 겹치는 멤버만 남깁니다.
type Status = "loading" | "success" | "error";
type NonError = Exclude<Status, "error">;
// 결과: "loading" | "success"
type OnlyError = Extract<Status, "error" | "fatal">;
// 결과: "error"
이 두 타입은 이름에서 알 수 있듯이 서로 대칭되는 동작을 합니다. 어떻게 이런 분기가 가능한지는 뒤에서 살펴볼 Conditional Type을 보면 자연스럽게 이해할 수 있습니다.
내장 유틸리티 타입의 상당수는 결국 한 가지 동작을 다른 형태로 풀어낸 것입니다. 바로 “기존 타입의 프로퍼티를 하나씩 훑으면서 일정한 규칙으로 새로운 프로퍼티를 만들어내는” 작업입니다. 이 동작을 직접 표현할 수 있는 문법이 바로 Mapped Type입니다. 이 절에서는 기본 문법부터 시작해, ‘Partial’이나 ‘Readonly’를 직접 구현해보고, 타입스크립트 4.1 이후 추가된 키 재매핑까지 단계적으로 살펴보겠습니다.
Mapped Type은 한 객체 타입의 프로퍼티를 순회하면서 새로운 객체 타입을 만들어내는 문법입니다. 자바스크립트에서 ‘for…in’으로 객체를 순회하는 것과 비슷한 개념이라고 이해하면 됩니다.
기본 형태는 다음과 같습니다.
type MyMapped<T> = {
[K in keyof T]: T[K];
};
‘keyof T’는 T의 모든 프로퍼티 키를 유니온으로 묶은 결과이고, [K in keyof T]는 그 키를 하나씩 꺼내 새로운 객체 타입을 구성하라는 의미입니다. 이를 통해 기존 타입의 구조는 유지하면서 각 프로퍼티에 변형을 가할 수 있습니다.

이제 Mapped Type을 사용해 내장 유틸리티 타입인 ‘Partial’과 ‘Readonly’를 직접 구현해 볼 수 있습니다.
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
‘?’는 프로퍼티를 옵셔널로 만들고, ‘readonly’는 읽기 전용으로 만듭니다. 단 두 줄로 강력한 유틸리티 타입을 만들 수 있다는 점에서 Mapped Type의 표현력을 확인할 수 있습니다.
타입스크립트 4.1 버전부터는 ‘as’ 키워드를 사용해 키 자체를 재매핑할 수 있습니다.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
// 결과: { getName: () => string; getAge: () => number; }
또한 ‘-?’나 ‘-readonly’처럼 빼기 기호를 붙이면 옵셔널 표시나 읽기 전용 수식어를 제거할 수도 있습니다.
type Concrete<T> = {
-readonly [K in keyof T]-?: T[K];
};
이는 ‘Readonly’나 ‘Partial’을 되돌리고 싶을 때 활용할 수 있는 패턴입니다.
Mapped Type이 타입의 “구조”를 순회하며 변환하는 도구라면, Conditional Type은 타입의 종류나 타입이 무엇이냐에 따라 결과를 다르게 결정하는 도구입니다. 입력 타입이 무엇이냐에 따라 분기되는 동적인 타입을 만들 수 있어, ‘Exclude’나 ‘ReturnType’ 같은 내장 유틸리티 타입의 토대가 됩니다. 여기서는 기본 문법을 먼저 살펴본 뒤, 유니온과 만났을 때 일어나는 분배 동작과 ‘infer’ 키워드까지 차례대로 알아보겠습니다.
Conditional Type은 자바스크립트의 삼항 연산자처럼 조건에 따라 타입을 결정하는 문법입니다.
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">;
// 결과: true
type B = IsString<42>;
// 결과: false
‘T extends U ? X : Y’ 형태로 작성하며, T가 U에 할당 가능한지 검사한 뒤 그 결과에 따라 다른 타입을 반환합니다. 이를 통해 입력 타입에 따라 동적으로 결과 타입을 만들어낼 수 있습니다.
Conditional Type이 유니온 타입과 만나면 흥미로운 일이 일어납니다. 검사 대상이 단일 타입 파라미터인 유니온일 경우, 타입스크립트는 유니온의 각 멤버에 조건을 분배해 적용합니다.
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// 결과: string[] | number[]

이 동작을 활용하면 앞서 살펴본 ‘Exclude’와 ‘Extract’를 다음처럼 직접 만들 수 있습니다.
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
T의 각 멤버에 대해 U에 속하는지를 검사하고, 결과에 따라 never로 제거하거나 그대로 남깁니다. 즉, Conditional Type 한 줄로 두 유틸리티 타입의 동작 원리가 설명됩니다.
‘infer’는 Conditional Type 안에서만 사용할 수 있는 키워드로, 검사 대상에서 특정 위치의 타입을 추론해 변수처럼 사용하도록 해줍니다.
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "kim" };
}
type User = ReturnTypeOf<typeof getUser>;
// 결과: { id: number; name: string; }
이 예시에서는 함수의 반환 타입을 ‘R’이라는 이름으로 추출해 결과 타입으로 사용했습니다. 내장 유틸리티 타입인 ‘ReturnType’, ‘Parameters’, ‘Awaited’ 등이 이 원리로 만들어졌습니다.
이제 Mapped Type과 Conditional Type을 결합해 실제로 사용할 만한 커스텀 유틸리티 타입을 만들어보겠습니다.
객체 타입에서 값의 타입이 ‘string’인 프로퍼티만 골라야 하는 상황을 가정해 보겠습니다.
type PickByType<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
interface UserProfile {
id: number;
name: string;
email: string;
isActive: boolean;
}
type StringFields = PickByType<UserProfile, string>;
// 결과: { name: string; email: string; }
Mapped Type으로 키를 순회하면서, Conditional Type으로 값의 타입을 검사해 조건에 맞지 않는 키는 never로 변환합니다. never로 매핑된 키는 결과 객체에서 자동으로 제거됩니다. 이 점에서 Mapped Type과 Conditional Type이 한 곳에서 자연스럽게 협력하는 모습을 확인할 수 있습니다.
특정 필드만 옵셔널로 만들고 나머지는 그대로 두는 유틸리티 타입도 만들 수 있습니다.
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
interface Article {
id: number;
title: string;
body: string;
publishedAt: Date;
}
type DraftArticle = PartialBy<Article, "publishedAt">;
// 결과: { id: number; title: string; body: string; publishedAt?: Date; }
Omit으로 대상 키를 제거한 결과와 Pick 후 Partial을 적용해 옵셔널로 만든 결과를 교집합으로 합쳐 새로운 타입을 만들어냅니다. 폼 입력값이나 임시 저장 객체처럼 단계적으로 채워나가는 데이터를 다룰 때 활용하기 좋은 패턴입니다.
지금까지 살펴본 도구들은 표현력이 뛰어나지만, 실무에서 사용할 때 몇 가지 고려할 점이 있습니다.
먼저 커스텀 유틸리티 타입은 추상화 수준이 높기 때문에 코드 가독성을 해칠 수 있습니다. 따라서 팀 단위로 코드를 다룰 때는 자주 쓰이는 패턴 정도로만 만들고, 일회성으로 쓰일 만한 타입은 그때그때 인라인으로 작성하는 편이 좋습니다. 또한 Conditional Type을 깊게 중첩하면 타입 추론 비용이 늘어나 IDE 성능이 떨어질 수 있습니다. 큰 프로젝트에서 특히 체감되는 부분이므로, 복잡한 타입은 적절히 분리하고 이름을 붙여 재사용하는 것을 권장합니다.
마지막으로 분배적 조건부 타입의 동작을 잊는 경우가 많습니다. 유니온 타입에 Conditional Type을 적용할 때 의도하지 않은 분배가 일어날 수 있으므로, 분배를 막고 싶다면 [T] extends [U] ? … : …처럼 튜플로 감싸는 패턴을 기억해 두면 좋습니다.
이번 글에서는 Mapped Type과 Conditional Type의 기본 문법부터 분배적 조건부 타입, infer 키워드, 그리고 이들을 결합해 만드는 커스텀 유틸리티 타입까지 살펴보았습니다.
핵심을 정리하면 다음과 같습니다.
이 두 가지를 이해하면 내장 유틸리티 타입의 동작 원리가 투명하게 드러납니다. 더 이상 내장 도구를 외워서 쓰는 것이 아니라, 필요한 순간에 직접 조립해 쓸 수 있게 되는 셈입니다. 이를 통해 타입스크립트로 작성한 코드의 표현력과 안정성이 한 단계 올라간다는 것을 체감할 수 있습니다.
<출처>
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.