
TypeScript Mapped Types 완벽 정복: 기본부터 고급 활용까지
Mapped type은 키(key)들을 순회하며 객체(object)나 튜플(tuple) 타입을 생성하는 기능으로, 다음과 같은 형태를 가집니다.
{[PropKey in PropKeyUnion]: PropValue}
이번 포스트에서는 mapped types가 어떻게 작동하는지 자세히 살펴보고 사용하는 예시를 알아보겠습니다.
Mapped types의 가장 중요한 사용 사례는 객체 변환과 튜플 매핑입니다.
소스 코드에서 계산되거나 추론된 타입을 보여주기 위해 npm
패키지인 asserttt
를 사용하는데요.
예를 들면 다음과 같습니다.
// 값의 타입
assertType<string>('abc');
assertType<number>(123);
// 타입의 동등성
type Pair<T> = [T, T];
type _ = Assert<Equal<
Pair<string>, [string,string]
>>;
Mapped Type이란 무엇일까요?
Mapped type을 사용하면 프로퍼티 키(property key)의 유니온(union)을 순회하여 객체를 생성할 수 있습니다.
type Obj = {
[PropKey in 'a' | 'b' | 'c']: number
};
type _ = Assert<Equal<
Obj,
{
a: number,
b: number,
c: number,
}
>>;
in
키워드 오른쪽에 있는 프로퍼티 키의 유니온은 반복 횟수를 결정합니다.
각 반복에서 PropKey
는 유니온의 요소 중 하나를 참조하며 프로퍼티가 생성됩니다.
- 기본적으로 프로퍼티 키는
PropKey
입니다. 이 기본 동작을 어떻게 재정의하거나 속성 생성을 완전히 막을 수 있는지는 나중에 알아보겠습니다. - 프로퍼티 값은 콜론(
:
) 오른쪽에 지정됩니다.
가장 일반적인 사용 사례: 기존 객체 타입 변환
Mapped type의 가장 일반적인 사용 사례는 기존 객체 타입을 변환하는 것입니다.
이때 프로퍼티 키는 해당 객체 타입에서 가져옵니다.
type InputObj = {
str: string,
num: number,
};
type OutputObj = {
[K in keyof InputObj]: Array<InputObj[K]>
};
type _1 = Assert<Equal<
OutputObj,
{
str: Array<string>,
num: Array<number>,
}
>>;
제네릭 타입(generic type)을 통해서도 동일한 변경을 수행할 수 있습니다.
type Arrayify<Obj> = {
[K in keyof Obj]: Array<Obj[K]>
};
type OutputObj2 = Arrayify<InputObj>;
type _2 = Assert<Equal<
OutputObj2, OutputObj
>>;
매핑은 타입의 종류(튜플, 배열, 객체 등)를 유지합니다.
Mapped type의 입력(튜플, 배열, 객체 등)은 출력의 형태를 결정합니다.
type WrapValues<T> = {
[Key in keyof T]: Promise<T[Key]>
};
type _ = [
// 읽기 전용 튜플 입력, 읽기 전용 튜플 출력
Assert<Equal<
WrapValues<readonly ['a', 'b']>,
readonly [Promise<'a'>, Promise<'b'>]
>>,
// 튜플 레이블 유지
Assert<Equal<
WrapValues<[labelA: 'a', labelB: 'b']>,
[labelA: Promise<'a'>, labelB: Promise<'b'>]
>>,
// 배열 입력, 배열 출력
Assert<Equal<
WrapValues<Array<string>>,
Array<Promise<string>>
>>,
// 읽기 전용 배열 입력, 읽기 전용 배열 출력
Assert<Equal<
WrapValues<ReadonlyArray<string>>,
ReadonlyArray<Promise<string>>
>>,
// 객체 입력, 객체 출력
Assert<Equal<
WrapValues<{ a: 1, b: 2 }>,
{ a: Promise<1>, b: Promise<2> }
>>,
// 읽기 전용 속성 유지
Assert<Equal<
WrapValues<{ readonly a: 1, readonly b: 2 }>,
{ readonly a: Promise<1>, readonly b: Promise<2> }
>>,
];
as
를 통한 키 리매핑(key remapping)을 사용하면 mapped type은 입력 타입을 유지하지 않습니다.
type KeyAsKeyToKey<T> = {
[K in keyof T as K]: K
};
type _ = Assert<Equal<
// KeyAsKeyToKey<>의 결과가 크기 때문에 Pick<> 사용
Pick<
KeyAsKeyToKey<['a', 'b']>,
'0' | '1' | 'length' | 'push' | 'join'
>,
{ // 튜플이 아닌 객체!
length: 'length';
push: 'push';
join: 'join';
0: '0';
1: '1';
}
>>;
이 결과는 튜플에 대한 실제 keyof
결과를 보여줍니다. as
가 없으면 mapped types는 튜플을 순회하기 전에 해당 결과를 필터링합니다.
Mapped Types를 통한 프로퍼티 값 변경
이번 섹션에서는 기존 객체 타입의 프로퍼티 키가 변환되는 더 많은 예시를 살펴보겠습니다.
예시: 인터페이스를 비동기적으로 만들기
제네릭 타입 Asyncify<Intf>
는 동기적 인터페이스 Intf
를 비동기적 인터페이스로 변환합니다.
interface SyncService {
factorize(num: number): Array<number>;
createDigest(text: string): string;
}
type AsyncService = Asyncify<SyncService>;
type _ = Assert<Equal<
AsyncService,
{
factorize: (num: number) => Promise<Array<number>>,
createDigest: (text: string) => Promise<string>,
}
>>;
Asyncify
의 정의는 다음과 같습니다.
type Asyncify<Intf> = {
[K in keyof Intf]: // (A)
Intf[K] extends (...args: infer A) => infer R // (B)
? (...args: A) => Promise<R> // (C)
: Intf[K] // (D)
};
- A 라인에서는
Intf
의 속성들을 순회하기 위해 mapped type을 사용합니다. - 각 속성
K
에 대해 속성 값Intf[K]
가 함수나 메서드인지 확인합니다(B 라인). - 만약 그렇다면,
infer
연산자를 사용하여 인수를 타입 변수A
로, 반환 타입을 타입 변수R
로 추출합니다. 이 변수들을 사용하여 반환 타입R
이Promise
로 감싸진 새로운 속성 값을 생성합니다(C 라인). - 그렇지 않다면, 속성 값은 변경되지 않습니다(D 라인).
예시: 객체의 열거형(enum)에 키 추가하기
다음과 같은 객체의 열거형을 생각해 봅시다.
const tokenDefs = {
number: {
key: 'number',
re: /[0-9]+/,
description: 'integer number',
},
identifier: {
key: 'identifier',
re: /[a-z]+/,
},
} as const;
.key
를 중복해서 명시하는 것을 피하고 싶은데요. 이를 위해 헬퍼 함수 addKey()
를 사용해 봅시다.
// 제공해야 하는 정보
interface InputTokenDef {
re: RegExp,
description?: string,
}
// addkeys()가 추가하는 정보
interface TokenDef extends InputTokenDef {
key: string,
}
const tokenDefs = addKeys({
number: {
re: /[0-9]+/,
description: 'integer number',
},
identifier: {
re: /[a-z]+/,
},
} as const);
addKeys()
가 타입 정보를 잃지 않는다는 점이 매우 유용합니다.
tokenDefs
의 계산된 타입은 .description
속성이 어디에 존재하고 어디에 존재하지 않는지 정확하게 기록합니다.
assertType<
{
readonly number: {
readonly re: RegExp,
readonly description: 'integer number',
key: string,
},
readonly identifier: {
readonly re: RegExp,
key: string,
},
}
>(tokenDefs);
이는 TypeScript가 tokenDefs.number.description
(존재함)은 사용하도록 허용하지만
tokenDefs.identifier.description
(존재하지 않음)은 허용하지 않음을 의미합니다.
addKeys()
는 다음과 같이 생겼습니다.
function addKeys<
T extends Record<string, InputTokenDef>
>(tokenDefs: T)
: {[K in keyof T]: T[K] & {key: string}} // (A)
{
const entries = Object.entries(tokenDefs);
const pairs = entries.map(
([key, def]) => [key, {key, ...def}]
);
return Object.fromEntries(pairs);
}
A 라인에서 &
를 사용하여 T[K]
와 {key: string}
의 속성을 모두 갖는 교차 타입(intersection type)을 생성합니다.
키 리매핑(as
)을 통한 프로퍼티 키 변경
Mapped type의 키 부분에서 as
를 사용하여 현재 프로퍼티의 프로퍼티 키를 변경할 수 있습니다.
{ [P in K as N]: X }
다음 예시에서는 as
를 사용하여 각 프로퍼티 이름 앞에 밑줄(_
)을 추가합니다.
type Point = {
x: number,
y: number,
};
type PrefixUnderscore<Obj> = {
[K in keyof Obj & string as `_${K}`]: Obj[K] // (A)
};
type X = PrefixUnderscore<Point>;
type _ = Assert<Equal<
PrefixUnderscore<Point>,
{
_x: number,
_y: number,
}
>>;
A 라인에서 템플릿 리터럴 타입 `_${K}`
는 K
가 심볼(symbol)인 경우 작동하지 않습니다. 그래서 keyof Obj
와 string
을 교차시켜 Obj
의 키 중 문자열인 키만 순회합니다.
프로퍼티 필터링
지금까지 객체 타입의 프로퍼티 키나 값을 변경하는 것만 다뤘습니다. 이번 섹션에서는 프로퍼티를 필터링하는 방법을 알아보겠습니다.
키 리매핑(as
)을 통한 프로퍼티 필터링
가장 쉬운 필터링 방법은 as
를 사용하는 것입니다. 프로퍼티 키로 never
를 사용하면 해당 프로퍼티는 결과에서 생략됩니다.
다음 예시에서는 값이 문자열이 아닌 모든 프로퍼티를 제거합니다.
type KeepStrProps<Obj> = {
[
Key in keyof Obj
as Obj[Key] extends string ? Key : never
]: Obj[Key]
};
type Obj = {
strPropA: 'A',
strPropB: 'B',
numProp1: 1,
numProp2: 2,
};
type _ = Assert<Equal<
KeepStrProps<Obj>,
{
strPropA: 'A',
strPropB: 'B',
}
>>;
키 유니온 필터링을 통한 프로퍼티 필터링
TypeScript에 as
를 통한 키 리매핑이 도입되기 전에는, mapped type으로 순회하기 전에 프로퍼티 키를 가진 유니온을 필터링해야 했습니다.
이전 예시를 as
없이 다시 해보겠습니다. 다음 Obj
타입에서 값이 문자열이 아닌 프로퍼티를 필터링하려고 합니다.
type Obj = {
strPropA: 'A',
strPropB: 'B',
numProp1: 1,
numProp2: 2,
};
다음 제네릭 헬퍼 타입은 값이 문자열인 모든 프로퍼티의 키를 수집합니다.
type KeysOfStrProps<T> = {
[K in keyof T]: T[K] extends string ? K : never // (A)
}[keyof T]; // (B)
type _1 = Assert<Equal<
KeysOfStrProps<Obj>,
'strPropA' | 'strPropB'
>>;
결과는 두 단계로 계산됩니다.
- A 라인: 먼저 각 프로퍼티 키
K
가 다음으로 매핑되는 객체를 생성합니다.- 프로퍼티 값
T[K]
가 문자열인 경우K
- 그렇지 않은 경우
never
- 프로퍼티 값
- B 라인: 그런 다음 방금 생성한 객체의 모든 프로퍼티 값을 추출합니다.
KeysOfStrProps
를 사용하면 이제 as
없이 KeepStrProps
를 쉽게 구현할 수 있습니다.
type KeepStrProps<Obj> = {
[Key in KeysOfStrProps<Obj>]: Obj[Key]
};
type _2 = Assert<Equal<
KeepStrProps<Obj>,
{
strPropA: 'A',
strPropB: 'B',
}
>>;
프로퍼티 유지를 위한 내장 유틸리티 타입: Pick<T, KeysToKeep>
다음 내장 유틸리티 타입을 사용하면 기존 객체 타입에서 유지하려는 프로퍼티를 지정하여 새 객체를 만들 수 있습니다.
/**
* T에서 키가 유니온 K에 있는 프로퍼티 집합을 선택합니다.
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
T
의 프로퍼티 키(keyof T
)의 부분 집합 K
를 순회하여 T
의 프로퍼티의 부분 집합을 유지합니다.
Pick
은 다음과 같이 사용됩니다.
type ObjectLiteralType = {
eeny: 1,
meeny: 2,
miny: 3,
moe: 4,
};
// %inferred-type: { eeny: 1; miny: 3; }
type Result = Pick<ObjectLiteralType, 'eeny' | 'miny'>;
프로퍼티 필터링을 위한 내장 유틸리티 타입: Omit<T, KeysToFilterOut>
다음 내장 유틸리티 타입을 사용하면 기존 객체 타입에서 생략하려는 프로퍼티를 지정하여 새 객체 타입을 만들 수 있습니다.
/**
* T의 프로퍼티에서 타입 K에 있는 프로퍼티를 제외한 타입을 구성합니다.
*/
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
설명:
K extends keyof any
는K
가 모든 가능한 프로퍼티 키의 부분 집합이어야 함을 의미합니다.type _ = Assert<Equal< keyof any, string | number | symbol >>;
Exclude<keyof T, K>
는T
의 키를 가져와K
에 언급된 모든 "값"을 제거함을 의미합니다.
Omit<>
은 다음과 같이 사용됩니다.
type ObjectLiteralType = {
eeny: 1,
meeny: 2,
miny: 3,
moe: 4,
};
type _ = Assert<Equal<
Omit<ObjectLiteralType, 'eeny' | 'miny'>,
{ meeny: 2; moe: 4; }
>>;
Mapped Types를 통한 수정자(modifier) 추가 및 제거
TypeScript에서 프로퍼티는 두 종류의 수정자를 가질 수 있습니다.
- 프로퍼티는 선택적(optional)일 수 있습니다: 수정자
?
- 프로퍼티는 읽기 전용(read-only)일 수 있습니다: 수정자
readonly
Mapped types를 통해 이러한 수정자를 추가하거나 제거할 수 있습니다.
예시: 선택적 수정자(?
) 추가
type AddOptional<T> = {
[K in keyof T]+?: T[K]
};
type RequiredArticle = {
title: string,
tags: Array<string>,
score: number,
};
type OptionalArticle = AddOptional<RequiredArticle>;
type _ = Assert<Equal<
OptionalArticle,
{
title?: string | undefined;
tags?: Array<string> | undefined;
score?: number | undefined;
}
>>;
표기법 +?
는 현재 프로퍼티를 선택적으로 만듦을 의미합니다. +
를 생략할 수도 있지만, 있는 편이 무슨 일이 일어나는지 이해하기 더 쉽다고 생각합니다.
내장 유틸리티 타입 Partial<T>
는 위의 제네릭 타입 AddOptional
과 동일합니다.
예시: 선택적 수정자(?
) 제거
type RemoveOptional<T> = {
[K in keyof T]-?: T[K]
};
type OptionalArticle = {
title?: string,
tags?: Array<string>,
score: number,
};
type RequiredArticle = RemoveOptional<OptionalArticle>;
type _ = Assert<Equal<
RequiredArticle,
{
title: string,
tags: Array<string>,
score: number,
}
>>;
표기법 -?
는 현재 프로퍼티를 필수(non-optional)로 만듦을 의미합니다.
내장 유틸리티 타입 Required<T>
는 위의 제네릭 타입 RemoveOptional
과 동일합니다.
예시: readonly
수정자 추가
type AddReadonly<Obj> = {
+readonly [K in keyof Obj]: Obj[K]
};
type MutableArticle = {
title: string,
tags: Array<string>,
score: number,
};
type ImmutableArticle = AddReadonly<MutableArticle>;
type _ = Assert<Equal<
ImmutableArticle,
{
readonly title: string,
readonly tags: Array<string>,
readonly score: number,
}
>>;
표기법 +readonly
는 현재 프로퍼티를 읽기 전용으로 만듦을 의미합니다.
+
를 생략할 수도 있지만, 있는 편이 무슨 일이 일어나는지 이해하기 더 쉽다고 생각합니다.
내장 유틸리티 타입 Readonly<T>
는 위의 제네릭 타입 AddReadonly
와 동일합니다.
예시: readonly
수정자 제거
type RemoveReadonly<Obj> = {
-readonly [K in keyof Obj]: Obj[K]
};
type ImmutableArticle = {
readonly title: string,
readonly tags: Array<string>,
score: number,
};
type MutableArticle = RemoveReadonly<ImmutableArticle>;
type _ = Assert<Equal<
MutableArticle,
{
title: string,
tags: Array<string>,
score: number,
}
>>;
표기법 -readonly
는 현재 프로퍼티를 변경 가능(non-read-only)하게 만듦을 의미합니다.
readonly
수정자를 제거하는 내장 유틸리티 타입은 없습니다.
프로퍼티 수정자 readonly
및 ?
(선택적) 감지
프로퍼티가 읽기 전용인지 감지
유틸리티 타입 IsReadonly
를 사용하는 것은 다음과 같습니다.
interface Car {
readonly year: number,
get maker(): string, // 기술적으로 `readonly`
owner: string,
}
type _1 = [
Assert<Equal<
IsReadonly<Car, 'year'>, true
>>,
Assert<Equal<
IsReadonly<Car, 'maker'>, true
>>,
Assert<Equal<
IsReadonly<Car, 'owner'>, false
>>,
];
아쉽게도 IsReadonly
를 구현하는 것은 복잡합니다.
현재 readonly
는 할당성에 영향을 미치지 않으며 extends
를 통해 감지할 수 없습니다.
type SimpleEqual<T1, T2> =
T1 extends T2
? T2 extends T1 ? true : false
: false
;
type _2 = Assert<Equal<
SimpleEqual<
{readonly year: number},
{year: number}
>,
true
>>;
그래서 더 엄격한 동등성 검사가 필요합니다.
type StrictEqual<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false
;
type _3 = [
Assert<Equal<
StrictEqual<
{readonly year: number},
{year: number}
>,
false
>>,
Assert<Equal<
StrictEqual<
{year: number},
{year: number}
>,
true
>>,
Assert<Equal<
StrictEqual<
{readonly year: number},
{readonly year: number}
>,
true
>>,
];
헬퍼 타입 Equal
은 핵(hack)이지만 현재 타입을 엄격하게 비교하는 가장 좋은 기술입니다.
어떻게 작동하는지는 제 라이브러리 asserttt
의 저장소에서 설명합니다.
이제 IsReadonly
를 구현할 수 있습니다 (github.com/inad9300의 코드를 기반으로 함).
type IsReadonly<T extends Record<any, any>, K extends keyof T> =
Not<StrictEqual<
{[_ in K]: T[K]}, // (A)
{-readonly [_ in K]: T[K]} // (B)
>>
;
type Not<B extends boolean> = B extends true ? false : true;
두 객체를 비교합니다.
- 하나는
T
의 키K
를 일반적인 매핑을 통해 생성됩니다(A 라인). - 다른 하나는
readonly
수정자를 제거하는 매핑을 통해 생성됩니다(B 라인).
관련 GitHub 이슈: "Allow identifying readonly properties in mapped types"
프로퍼티가 선택적인지 감지
프로퍼티가 선택적인지 감지하는 헬퍼 타입 IsOptional
을 사용하는 것은 다음과 같습니다.
interface Person {
name: undefined | string;
age?: number;
}
type _1 = [
Assert<Equal<
IsOptional<Person, 'name'>, false
>>,
Assert<Equal<
IsOptional<Person, 'age'>, true
>>,
];
IsOptional
은 선택적 프로퍼티를 감지하기 쉽기 때문에 IsReadonly
보다 구현하기 쉽습니다.
type IsOptional<T extends Record<any, any>, K extends keyof T> =
{} extends Pick<T, K> ? true : false
;
어떻게 작동할까요?
Pick
이 생성하는 결과를 살펴보겠습니다.
type _2 = [
Assert<Equal<
Pick<Person, 'name'>,
{ name: undefined | string }
>>,
Assert<Equal<
Pick<Person, 'age'>,
{ age?: number | undefined }
>>,
];
후자의 객체만 빈 객체 {}
에 할당 가능합니다.
레코드(Record)는 Mapped Types입니다.
내장 유틸리티 타입 Record
는 단순히 mapped type의 별칭(alias)입니다.
/**
* 타입 T의 프로퍼티 K 집합으로 타입을 구성합니다.
*/
type Record<K extends keyof any, T> = {
[P in K]: T;
};
다시 한번, keyof any
는 "유효한 프로퍼티 키"를 의미합니다.
type _ = Assert<Equal<
keyof any,
string | number | symbol
>>;
'Javascript' 카테고리의 다른 글
TypeScript 심볼 완벽 분석: 타입 레벨에서의 심볼 활용과 고급 패턴 (0) | 2025.03.15 |
---|---|
TypeScript 조건부 타입 완벽 가이드: 유니온 타입과 유틸리티 타입 활용의 모든 것 (0) | 2025.03.15 |
TypeScript의 infer 키워드로 복합 타입에서 원하는 부분만 깔끔하게 추출하기 (0) | 2025.03.13 |
TypeScript satisfies 연산자 완벽 정리: 타입 체크의 새로운 강자 (0) | 2025.03.03 |
TypeScript에서 읽기 전용 속성 완벽 정리: readonly 키워드 활용법 (0) | 2025.03.03 |