타입스크립트 심화 #2 Mapped types

5 분 소요

#1 keyof와 typeof에서 객체의 키를 union으로 모으는 법을 봤습니다. 그 위에 한 단계 더 얹으면 — 객체 타입을 통째로 변환하는 mapped types가 됩니다. Partial, Required, Readonly 같은 유틸리티 타입의 정체가 바로 이것입니다.

기본 형태 #

mapped type은 다음 한 줄짜리 문법입니다.

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] 위치에 다른 타입을 적으면 변환이 시작됩니다.

값 타입을 모두 string으로
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 하나만 추가하면 직접 짤 수 있습니다.

Partial 직접 구현
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 표시가 제거됩니다.

Required 직접 구현
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를 붙입니다.

Readonly 직접 구현
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 라서 풀고 싶을 때 가끔 씁니다.

readonly 제거
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

type M = Mutable<Readonly<{ id: string }>>;
// { id: string }

PickOmit도 같은 계열 #

Pick<T, K>Omit<T, K>도 mapped type입니다. 차이는 돌릴 키 집합이 어디서 오느냐 뿐입니다.

Pick 직접 구현
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를 써서 키 집합을 뺍니다.

Omit 직접 구현
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 절을 쓰면 키 이름을 바꿀 수 있습니다.

setter 이름 자동 생성
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;
// }

세 가지 도구가 합쳐져 있습니다.

  1. template literal type`set${...}`으로 문자열 타입 합성. #4에서 본격적으로.
  2. Capitalize<S> — 빌트인 도우미. 첫 글자 대문자.
  3. 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이라는 게 의미 있습니다. 아래 한 줄로 정의됩니다.

Record 직접 구현
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을 자기 자신으로 부르면 가능합니다.

DeepReadonly
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도 흔합니다.

DeepPartial — 폼 patch 같은 경우
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 같은 걸 짤 때 함수까지 재귀하면 깨질 수 있습니다. 보통은 다음처럼 원시값과 함수를 명시적으로 제외합니다.

실전적인 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 type
  • as 절로 키 이름 바꾸기 — `as `set${...}` 같은 패턴
  • as ... never로 키 제거
  • 자기 자신을 부르면 깊은 재귀 변환 (DeepReadonly/DeepPartial)
  • 재귀할 때 원시값/함수 제외에 주의

다음 글(#3 Conditional types와 infer)에서는 위에서 살짝 등장한 T extends U ? X : Y 문법을 본격적으로 다룹니다. infer 키워드까지 익히면 ReturnType, Parameters, Awaited 같은 빌트인을 직접 짜는 곳까지 갑니다.

X