타입스크립트 심화 #2 Mapped types
#1 keyof와 typeof 에서 객체의 키를 union으로 모으는 법을 봤습니다. 그 위에 한 단계 더 얹으면 — 객체 타입을 통째로 변환하는 mapped types 가 됩니다. Partial, Required, Readonly 같은 유틸리티 타입의 정체가 바로 이거예요.
기본 형태
mapped type은 다음 한 줄짜리 문법입니다.
type MyType<T> = {
[K in keyof T]: T[K];
};
// MyType<{ a: string; b: number }>
// = { a: string; b: number }[K in keyof T] 가 핵심입니다. "T의 키 K 하나하나에 대해" 라는 뜻이에요. 그 안의 T[K] 는 #1에서 본 인덱스 액세스 — 그 키로 꺼낸 값 타입.
위 예시는 입력을 그대로 복제할 뿐이지만, T[K] 자리에 다른 타입을 적으면 변환이 시작됩니다.
type Stringify<T> = {
[K in keyof T]: string;
};
type S = Stringify<{ id: number; age: number; ok: boolean }>;
// { id: string; age: string; ok: string }Partial 직접 만들어 보기
Partial<T> 는 모든 필드를 optional로 만드는 빌트인 타입이에요. modifier 하나만 추가하면 직접 짤 수 있습니다.
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
type Patch = MyPartial<{ id: string; name: string; age: number }>;
// { id?: string; name?: string; age?: number }? 가 핵심입니다. [K in keyof T]? 처럼 키 자리 뒤에 붙이면 모든 필드가 optional이 돼요.
Required — 모든 필드를 필수로
반대 방향. modifier에 -? 를 붙이면 optional 표시가 제거됩니다.
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};
type Strict = MyRequired<{ id?: string; name?: string }>;
// { id: string; name: string }? 와 -? 가 한 쌍입니다. 빌트인 Partial/Required 는 정확히 이 모양으로 정의되어 있어요. 어쩐지 평생 외워야 할 마법 같던 게, 사실 한 줄짜리 mapped type 이었습니다.
Readonly 도 같은 방식
값을 읽기 전용으로 만들 때는 readonly modifier를 붙입니다.
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
type Frozen = MyReadonly<{ id: string; name: string }>;
// { readonly id: string; readonly name: string }-readonly 도 가능합니다. 외부 라이브러리 타입이 readonly 라서 풀고 싶을 때 가끔 씁니다.
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type M = Mutable<Readonly<{ id: string }>>;
// { id: string }Pick 과 Omit 도 같은 가족
Pick<T, K> 와 Omit<T, K> 도 mapped type 입니다. 차이는 돌릴 키 집합이 어디서 오느냐 뿐이에요.
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
type UserBasic = MyPick<{ id: string; name: string; age: number }, 'id' | 'name'>;
// { id: string; name: string }[P in K] — keyof T 가 아니라 외부에서 받은 union K 를 돈 거예요. mapped type은 사실 "어떤 union이든 그대로 키로 쓸 수 있는" 도구라는 게 보입니다.
Omit 은 살짝 더 손이 갑니다. 빌트인 Exclude 를 써서 키 집합을 빼요.
type MyOmit<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]: T[P];
};
type WithoutAge = MyOmit<{ id: string; name: string; age: number }, 'age'>;
// { id: string; name: string }Exclude<U, V> 는 union U에서 V에 해당하는 멤버를 빼냅니다. 이건 #3 conditional types에서 직접 짜 봅니다.
키를 다른 이름으로 바꾸기 — as 절
여기서부터는 빌트인 유틸리티에는 없는, 직접 만들어야 의미 있는 기능입니다. as 절을 쓰면 키 이름을 바꿀 수 있어요.
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
type UserSetters = Setters<{ name: string; age: number }>;
// {
// setName: (value: string) => void;
// setAge: (value: number) => void;
// }세 가지 도구가 합쳐져 있습니다.
- template literal type —
`set${...}`으로 문자열 타입 합성. #4에서 본격적으로. Capitalize<S>— 빌트인 도우미. 첫 글자 대문자.string & K— symbol/number 키를 거르고 string 키만.
name 키를 setName 으로 바꾸고, 그 값 타입을 setter 함수로 만들어요. 라이브러리 만들 때 이런 자동 생성 패턴이 자주 등장합니다.
as + never = 키 제거
as 자리에 never 를 두면 그 키는 결과 타입에서 사라집니다.
type FunctionsOnly<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K];
};
type Mixed = {
id: string;
name: string;
greet: () => void;
fetch: () => Promise<void>;
};
type Methods = FunctionsOnly<Mixed>;
// { greet: () => void; fetch: () => Promise<void> }값 타입이 함수가 아니면 키를 never 로 바꿔서 제거. 이게 가능해지면서 mapped type 표현력이 한 단계 올라옵니다.
빌트인 유틸리티 정리 — 이제 보이는 것
이 시점에서 기초 강좌 #7 에서 본 유틸리티 타입의 절반 이상을 직접 짤 수 있습니다.
| 유틸리티 | 정의 |
|---|---|
Partial<T> | { [K in keyof T]?: T[K] } |
Required<T> | { [K in keyof T]-?: T[K] } |
Readonly<T> | { readonly [K in keyof T]: T[K] } |
Pick<T, K> | { [P in K]: T[P] } |
Record<K, V> | { [P in K]: V } |
Record<K, V> 도 mapped type이라는 게 의미 있어요. 아래 한 줄로 정의됩니다.
type MyRecord<K extends keyof any, V> = {
[P in K]: V;
};
type Roles = MyRecord<'admin' | 'user' | 'guest', boolean>;
// { admin: boolean; user: boolean; guest: boolean }K extends keyof any 가 보이는데, 이건 "string | number | symbol — 객체의 키가 될 수 있는 모든 것"이라는 뜻입니다. 자주 쓰는 관용구예요.
한 단계 더 — 깊게 들어가는 변환
객체가 중첩되어 있을 때 재귀적으로 변환하고 싶은 경우가 있습니다. mapped type을 자기 자신으로 부르면 가능해요.
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
type Settings = {
theme: { color: string; font: string };
user: { id: string; flags: { admin: boolean } };
};
type Frozen = DeepReadonly<Settings>;
// 모든 중첩 객체까지 readonly로T[K] extends object ? ... : ... 는 #3 에서 본격적으로 다룰 conditional type입니다. mapped type 안에서 "값이 객체면 다시 mapped, 아니면 그대로" 라는 분기를 만든 거예요. 두 도구가 만나면 표현력이 폭발적으로 올라갑니다.
같은 패턴으로 DeepPartial 도 흔합니다.
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? DeepPartial<T[K]>
: T[K];
};부분 업데이트, 설정 override, 폼 patch 같은 자리에 어울려요. 라이브러리들이 이 모양을 자주 채택합니다.
함정 — 함수와 배열은 object 에 포함됨
T[K] extends object 는 의외로 넓은 조건입니다. 함수, 배열, Date, Map 모두 object 입니다. DeepReadonly 같은 걸 짤 때 함수까지 재귀하면 깨질 수 있어요. 보통은 다음처럼 원시값과 함수를 명시적으로 제외합니다.
type Primitive = string | number | boolean | null | undefined | bigint | symbol;
type DeepReadonly<T> = T extends Primitive | Function
? T
: { readonly [K in keyof T]: DeepReadonly<T[K]> };이 단계까지 오면 conditional types가 익숙해야 매끄럽습니다. 다음 글에서 본격적으로 손에 익혀요.
마무리
이번 글에서 정리한 내용:
- mapped type 기본 —
{ [K in keyof T]: T[K] } - modifier —
?/-?(optional),readonly/-readonly(readonly) Partial,Required,Readonly,Pick,Record가 모두 mapped typeas절로 키 이름 바꾸기 —`as `set${...}`같은 패턴as ... never로 키 제거- 자기 자신을 부르면 깊은 재귀 변환 (DeepReadonly/DeepPartial)
- 재귀할 때 원시값/함수 제외에 주의
다음 글(#3 Conditional types와 infer)에서는 위에서 살짝 등장한 T extends U ? X : Y 문법을 본격적으로 다룹니다. infer 키워드까지 익히면 ReturnType, Parameters, Awaited 같은 빌트인을 직접 짜는 곳까지 갑니다.