타입스크립트 심화 #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 같은 빌트인을 직접 짜는 곳까지 갑니다.