Javascript

TypeScript Mapped Types 완벽 정복: 기본부터 고급 활용까지

드리프트2 2025. 3. 13. 22:48

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로 추출합니다. 이 변수들을 사용하여 반환 타입 RPromise로 감싸진 새로운 속성 값을 생성합니다(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 Objstring을 교차시켜 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'
>>;

 

결과는 두 단계로 계산됩니다.

  1. A 라인: 먼저 각 프로퍼티 키 K가 다음으로 매핑되는 객체를 생성합니다.
    • 프로퍼티 값 T[K]가 문자열인 경우 K
    • 그렇지 않은 경우 never
  2. 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 anyK가 모든 가능한 프로퍼티 키의 부분 집합이어야 함을 의미합니다.
  • 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
>>;