매핑된 타입
keyof로 객체 타입의 키를 하나씩 돌며 속성마다 같은 규칙을 적용해 새 타입을 만드는 매핑된 타입의 문법과, readonly와 ? 수정자를 더하고 빼는 방법, as로 키의 이름을 바꾸거나 걸러 내는 키 다시 매핑, 이 원리로 만든 표준 유틸리티를 다룹니다.
회원 정보를 나타내는 타입이 있을 때, 같은 속성을 모두 읽기 전용으로 만든 타입이나 모든 속성을 생략할 수 있게 만든 타입이 따로 필요할 때가 있습니다. 속성마다 다시 적으면 원래 타입이 바뀔 때마다 여러 곳을 함께 고쳐야 합니다. 매핑된 타입(mapped type)은 원래 타입의 키를 하나씩 돌며 같은 규칙을 적용해, 새 타입을 계산으로 만들어 냅니다.
이 글은 매핑된 타입의 기본 문법에서 시작해, 속성의 readonly와 ?를 더하고 빼는 수정자, as로 키의 이름을 바꾸거나 걸러 내는 키 다시 매핑을 차례로 봅니다. 마지막으로 이 원리로 만들어진 표준 유틸리티를 정리합니다. 예제는 ts-first 프로젝트에 mapped.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.
기본 문법
매핑된 타입은 { [P in K]: 타입 }의 모양입니다. K는 키들의 유니온이고, P는 그 키를 하나씩 받는 이름이며, 콜론 뒤에는 그 키의 속성이 가질 타입을 적습니다. K 자리에 keyof T를 쓰고 값 자리에 T[P]를 쓰면, T의 키를 모두 돌며 원래의 속성 타입을 그대로 옮겨 적는 셈이 됩니다.
interface User {
id: number;
name: string;
email?: string;
}
type ReadonlyType<T> = {
readonly [P in keyof T]: T[P];
};
type ReadonlyUser = ReadonlyType<User>;
const user: ReadonlyUser = { id: 1, name: "김타입" };
console.log(user.name);김타입실행 결과는 이름을 읽은 김타입 한 줄뿐입니다. ReadonlyType이 한 일은 실행이 아니라 타입에서 드러나며, 컴파일한 JavaScript에는 readonly가 남지 않습니다.
ReadonlyUser에 마우스를 올리면, 편집기는 계산한 결과를 다음과 같이 보여 줍니다.
type ReadonlyUser = {
readonly id: number;
readonly name: string;
readonly email?: string | undefined;
};[P in keyof T]는 User의 키 id, name, email을 차례로 P에 넣고, 각 속성에 readonly를 붙인 뒤 원래의 타입 T[P]를 그대로 씁니다. keyof T를 돌며 만든 매핑된 타입은 원래 속성의 ?도 그대로 지키므로, email은 여전히 생략할 수 있습니다. 그래서 user.id = 2;는 Cannot assign to 'id' because it is a read-only property.로 진단되지만, 읽기는 그대로 할 수 있습니다.
수정자 더하고 빼기
속성 이름 앞의 readonly와 뒤의 ?를 매핑 수정자(mapping modifier)라고 합니다. 앞에 +를 붙이면 수정자를 더하고 -를 붙이면 빼며, 기호를 생략하면 +와 같습니다. 그래서 앞의 readonly [P in keyof T]는 +readonly [P in keyof T]와 같은 뜻입니다.
type PartialType<T> = {
[P in keyof T]?: T[P];
};
type RequiredType<T> = {
[P in keyof T]-?: T[P];
};
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
interface Config {
port?: number;
host?: string;
debug?: boolean;
}
const draft: PartialType<{ id: number; name: string }> = { name: "키보드" };
const strict: RequiredType<Config> = { port: 3000, host: "localhost", debug: true };
const editable: Mutable<ReadonlyUser> = { id: 1, name: "김타입" };
editable.id = 2;
console.log(draft.name, strict.port, editable.id);키보드 3000 2PartialType은 모든 속성에 ?를 더해 { name: "키보드" }처럼 일부만 적은 객체를 받고, RequiredType은 -?로 ?를 빼서 Config의 세 속성을 모두 요구합니다. 그래서 const missing: RequiredType<Config> = { port: 8080 };은 host와 debug가 빠졌다는 진단을 받습니다. Mutable은 -readonly로 읽기 전용을 빼므로, ReadonlyUser에서 만든 타입인데도 editable.id = 2가 허용됩니다.
더 깊이: 배열과 튜플을 매핑하면
keyof T를 그대로 도는 매핑된 타입에 배열이나 튜플을 넣으면, 컴파일러는 push나 length 같은 멤버까지 바꾼 이상한 객체 타입을 만들지 않습니다. TypeScript 3.1부터 이런 매핑은 배열에서는 배열을, 튜플에서는 튜플을 만들고 숫자 이름의 요소만 바꿉니다. 그래서 각 값을 Promise로 감싸는 매핑에 [number, number]를 넣으면 [Promise<number>, Promise<number>]가 나옵니다. 릴리스 노트는 이 동작이 T를 그대로 돌며 원래 모양을 지키는 형태, 곧 준동형(homomorphic) 매핑된 타입에 적용된다고 밝힙니다.
수정자도 이 규칙을 따릅니다. 3.4 이전에는 Readonly<number[]>가 그냥 number[]여서 배열에는 아무 효과가 없었는데, TypeScript 3.4부터는 매핑된 타입의 readonly가 배열을 readonly number[]로, 튜플을 readonly [string, boolean]으로 바꿉니다. 반대로 -readonly를 쓴 매핑은 읽기 전용 배열과 튜플을 다시 바꿀 수 있는 쪽으로 돌려놓습니다. 다만 타입 앞에 직접 붙이는 readonly는 배열과 튜플 문법에만 쓸 수 있어서, readonly Set<number>나 readonly Array<boolean>은 오류이고 ReadonlySet이나 ReadonlyArray를 써야 합니다.
키 다시 매핑
TypeScript 4.1부터는 [P in keyof T as 새 키]처럼 as 뒤에 새 키를 적어, 속성의 이름을 바꿀 수 있습니다. 이것을 키 다시 매핑(key remapping)이라 하며, 템플릿 리터럴 타입과 함께 쓰면 원래 이름에 글자를 붙인 새 이름을 만들 수 있습니다.
interface UserProfile {
name: string;
age: number;
isActive: boolean;
}
type Getters<T> = {
[P in keyof T as `get${Capitalize<string & P>}`]: T[P];
};
type UserProfileGetters = Getters<UserProfile>;
const userGetters: UserProfileGetters = {
getName: "Alice",
getAge: 30,
getIsActive: true,
};
console.log(userGetters.getName, userGetters.getAge);Alice 30Capitalize는 문자열 리터럴 타입의 첫 글자를 대문자로 바꾸는 표준 타입이고, `get${...}`은 그 앞에 get을 붙인 새 문자열 리터럴 타입을 만듭니다. 키는 문자열 말고 숫자나 심벌일 수도 있으므로, string & P로 문자열인 키만 골라 Capitalize에 넘깁니다. 결과 UserProfileGetters는 { getName: string; getAge: number; getIsActive: boolean; }입니다.
TYPE / RUNTIME / CONTRACT
키를 순회해 새로운 객체 타입을 계산한다
Readonly 예제의 User와 키 이름 변환 예제의 UserProfile을 구분해 읽습니다. 객체를 만드는 실행 코드는 따로 작성합니다.
READONLY
값 타입을 유지한다
ReadonlyType은 [P in keyof T]의 각 속성을 readonly로 만듭니다. User의 선택적 email은 선택적 상태를 유지합니다.
PARTIAL / REQUIRED
선택 여부를 바꾼다
OptionalProduct는 {}도 허용합니다. StrictConfig는 port, host, debug를 모두 요구합니다. -?는 선택 표시를 제거합니다.
AS REMAP
새 이름의 값 속성
Getters의 getName은 "Alice", getAge는 30, getIsActive는 true입니다. 이 예제의 타입은 getter 함수가 아닙니다.
CALLBACK
값 타입을 함수 인자로 사용
OnChangeCallbacks는 속성마다 선택적 콜백을 만듭니다. 본문의 onChangeFirstName과 onChangeEmail은 각각 문자열을 받습니다.
매핑 타입은 키와 값의 타입 관계를 계산합니다. 이름을 바꾸거나 readonly를 선언해도 실제 객체의 키나 변경 가능성이 자동으로 바뀌지는 않습니다.
위 그림처럼 세 키는 각자 따로 이름이 바뀌고, 값의 타입은 원래 속성의 것을 그대로 가져옵니다. 여기서 getName은 이름만 get으로 시작할 뿐 문자열을 담는 속성이지, 호출하는 함수가 아닙니다. 타입은 실행 중에 객체나 getter 함수를 만들어 주지 않으므로, 실제 값은 위의 객체 리터럴처럼 따로 적어야 합니다.
값의 자리에 함수 타입을 쓰면 이름과 함께 값의 모양도 바꿀 수 있습니다. 다음 OnChangeCallbacks는 폼의 필드마다, 그 필드의 값을 받는 선택적 콜백 속성을 만듭니다.
interface FormFields {
firstName: string;
email: string;
}
type OnChangeCallbacks<T> = {
[P in keyof T as `onChange${Capitalize<string & P>}`]?: (value: T[P]) => void;
};
const formHandler: OnChangeCallbacks<FormFields> = {
onChangeFirstName: (value) => console.log(`First name changed to: ${value}`),
};
formHandler.onChangeFirstName?.("John");
formHandler.onChangeEmail?.("[email protected]");First name changed to: JohnformHandler에는 onChangeFirstName만 넣었으므로, onChangeEmail은 undefined이고 ?.가 호출을 건너뜁니다. 콜백의 매개변수 value에는 타입을 적지 않았지만, 매핑된 타입이 정한 (value: string) => void에서 문맥으로 string이 정해집니다.
as 뒤의 새 키가 never가 되면, 그 속성은 결과에서 빠집니다. 이 성질을 조건부 타입과 함께 쓰면 값의 타입에 따라 속성을 걸러 낼 수 있으며, 예를 들어 [P in keyof T as T[P] extends Function ? never : P]: T[P]는 함수인 속성을 모두 뺀 타입을 만듭니다.
매핑된 타입으로 만든 표준 유틸리티
자주 쓰는 변환은 표준 라이브러리에 유틸리티 타입으로 들어 있습니다. 이 글에서 직접 만든 타입들은 대부분 표준 유틸리티와 같은 선언입니다.
| 유틸리티 | 하는 일 | 선언의 핵심 |
|---|---|---|
Partial<T> | 모든 속성을 선택적으로 만든다 | [P in keyof T]?: T[P] |
Required<T> | 모든 속성을 필수로 만든다 | [P in keyof T]-?: T[P] |
Readonly<T> | 모든 속성을 읽기 전용으로 만든다 | readonly [P in keyof T]: T[P] |
Pick<T, K> | 키 K의 속성만 남긴다 | [P in K]: T[P] |
Record<K, T> | 키 K마다 값이 T인 객체 타입을 만든다 | [P in K]: T |
Omit<T, K> | 키 K의 속성을 뺀다 | Pick<T, Exclude<keyof T, K>> |
Omit은 매핑된 타입을 직접 쓰지 않고, 앞 글의 Exclude로 남길 키를 계산한 뒤 Pick에 넘깁니다. 이 유틸리티들의 쓰임새는 유틸리티 타입에서 자세히 보고, 매핑된 타입의 전체 문법은 공식 Handbook의 매핑된 타입에 있습니다.
정리
- 매핑된 타입
{ [P in K]: 타입 }은 키의 유니온K를 하나씩 돌며 속성을 만들고,keyof T와T[P]를 쓰면 원래 타입의 속성을 옮겨 적습니다. keyof T를 돌며 만든 매핑된 타입은 원래 속성의readonly와?를 그대로 지킵니다.- 수정자 앞의
+는 더하고-는 빼며,-?는 모든 속성을 필수로,-readonly는 모든 속성을 바꿀 수 있게 만듭니다. as로 키를 다시 매핑하면 속성의 이름을 바꿀 수 있고, 새 키가never가 된 속성은 결과에서 빠집니다.- 매핑된 타입은 타입만 계산하므로, 그 타입을 갖춘 실제 객체와 함수는 따로 만들어야 합니다.
연습 문제
1. 숫자 키가 있는 타입
다음 MixedGetters에는 어떤 속성이 남나요? { getName: "지수" }와 { getName: () => "지수" } 가운데 이 타입에 맞는 객체는 어느 것인가요?
interface MixedKeys {
name: string;
2: boolean;
}
type MixedGetters = Getters<MixedKeys>;해설 보기
keyof MixedKeys는 "name" | 2입니다. 문자열 키 "name"은 getName으로 바뀌고 값은 string이 되지만, 숫자 키 2는 string & 2가 never이므로 새 키가 never가 되어 빠집니다. 결과는 { getName: string }이고, 값이 문자열이어야 하므로 { getName: "지수" }가 맞으며 함수를 넣은 객체는 진단됩니다.
2. 빠진 필수 속성
const missing: RequiredType<Config> = { port: 8080 };은 왜 진단되나요? 같은 객체를 PartialType<Config>의 변수에 넣으면 어떻게 되나요?
해설 보기
RequiredType은 -?로 Config의 모든 ?를 빼므로, port와 host, debug가 모두 필수 속성이 됩니다. 그래서 host와 debug가 빠진 객체는 진단됩니다. PartialType<Config>는 모든 속성이 이미 선택적이므로 같은 객체를 받으며, 원래 Config도 모든 속성이 선택적이어서 결과는 Config와 같은 모양입니다.
3. 함수 속성 걸러 내기
type DataOnly<T> = { [P in keyof T as T[P] extends Function ? never : P]: T[P] };일 때, DataOnly<{ id: number; save: () => void; title: string }>는 어떤 타입인가요?
해설 보기
id와 title은 값이 함수가 아니므로 키가 그대로 남고, save는 값이 함수여서 새 키가 never가 되어 빠집니다. 결과는 { id: number; title: string }입니다. 이렇게 하면 서버로 보낼 데이터처럼 메서드를 뺀 모양을 원래 타입에서 계산해 낼 수 있습니다.
다음 글 인덱스 타입에서는 이 글에서 쓴 keyof와 T[P]를 더 자세히 보고, 인덱스 시그니처가 있는 타입에서 키와 값의 타입이 어떻게 계산되는지 다룹니다.