본문 바로가기

매핑된 타입

keyof로 기존 타입의 키를 순회해 속성 타입과 선택·읽기 전용 수정자를 변환하고 키를 다시 매핑합니다.

타입스크립트의 또 다른 강력한 고급 기능이 매핑된 타입(Mapped Types)입니다.

매핑된 타입은 기존 타입의 속성을 순회하면서 새로운 타입으로 변환하는 방식입니다.

자바스크립트의 map이 배열 요소를 바꾸듯, 매핑된 타입은 객체 타입의 속성을 변환해 새 객체 타입을 만듭니다.

이 기능을 통해 우리는 기존 타입을 기반으로 다양한 변형된 타입을 손쉽게 만들 수 있으며, 이는 타입 정의의 중복을 줄이고 코드의 유연성을 크게 높여줍니다.


매핑된 타입의 기본 문법

매핑된 타입의 기본 문법은 다음과 같습니다.

type NewType<T> = {
  [P in K]: SomeType;
};
  • T: 변환의 대상이 되는 기존 타입 (주로 객체 타입)
  • P: 기존 타입 T의 속성 이름 하나하나를 나타내는 타입 변수
  • in: for...in 루프처럼 K에 있는 속성들을 순회하겠다는 의미
  • K: 순회할 속성 이름들의 유니온 타입 (주로 keyof T를 사용)
  • SomeType: P에 해당하는 새로운 속성에 부여할 타입

예시를 통해 살펴보겠습니다.

기존 객체 타입의 모든 속성을 readonly로 만드는 Readonly<T> 유틸리티 타입을 직접 구현해 봅시다.

interface User {
  id: number;
  name: string;
  email?: string;
}

// ReadonlyType은 User 타입의 모든 속성을 읽기 전용으로 만듭니다.
type ReadonlyType<T> = {
  readonly [P in keyof T]: T[P];
};

type ReadonlyUser = ReadonlyType<User>;
/*
// ReadonlyUser의 실제 타입은 다음과 같습니다.
type ReadonlyUser = {
  readonly id: number;
  readonly name: string;
  readonly email?: string;
};
*/

const user: ReadonlyUser = {
  id: 1,
  name: "김타입",
  email: "[email protected]",
};

// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property.
user.name; // 접근은 가능

이 예시에서 [P in keyof T]는 T 타입의 모든 속성 이름(id, name, email)을 P에 할당하며 순회합니다.

그리고 각 P에 readonly 한정자를 붙이고, 원래 T 타입의 P 속성 타입(T[P])을 그대로 사용합니다.


매핑 수정자

매핑된 타입을 사용할 때, 기존 속성의 readonly 또는 ? (선택적) 한정자를 추가하거나 제거할 수 있습니다.

이를 매핑 수정자(Mapping Modifiers)라고 합니다.

  • + 또는 -: 한정자를 추가하거나 제거합니다.
    • +readonly: readonly 한정자를 추가
    • -readonly: readonly 한정자를 제거
    • +?: ? (선택적) 한정자를 추가
    • -?: ? (선택적) 한정자를 제거

이 +나 - 기호는 생략될 수 있으며, 생략되면 기본적으로 +와 동일하게 동작합니다.

예를 들어, readonly [P in keyof T]는 +readonly [P in keyof T]와 같습니다.

예시를 통해 각각의 사용법을 살펴보겠습니다.

모든 속성을 선택적으로 만들기 (Partial<T> 구현)
type PartialType<T> = {
  [P in keyof T]?: T[P]; // '?'를 붙여 모든 속성을 선택적으로 만듭니다.
};

interface Product {
  id: number;
  name: string;
  price: number;
}

type OptionalProduct = PartialType<Product>;
/*
type OptionalProduct = {
  id?: number;
  name?: string;
  price?: number;
};
*/
const p1: OptionalProduct = { name: "키보드" }; // 유효함
const p2: OptionalProduct = {};               // 유효함
모든 속성을 필수로 만들기 (Required<T> 구현)
type RequiredType<T> = {
  [P in keyof T]-?: T[P]; // '-'와 '?'를 붙여 모든 속성을 필수로 만듭니다.
};

interface Config {
  port?: number;
  host?: string;
  debug?: boolean;
}

type StrictConfig = RequiredType<Config>;
/*
type StrictConfig = {
  port: number;
  host: string;
  debug: boolean;
};
*/

// const c1: StrictConfig = { port: 8080 }; // Error: 'host' 및 'debug' 속성이 누락됨
const c2: StrictConfig = { port: 3000, host: "localhost", debug: true }; // 유효함

매핑 수정자는 원본 객체 타입을 복사하면서 속성의 선택성이나 읽기 전용 여부만 바꿀 때 사용합니다.


키 다시 매핑

keyof T의 키 P와 T[P]의 값 타입으로 새 속성 타입을 만든다. 본문의 Getters는 함수가 아니라 getName:string 같은 값 속성이다.

TYPE / RUNTIME / CONTRACT

키를 순회해 새로운 객체 타입을 계산한다

Readonly 예제의 User와 키 이름 변환 예제의 UserProfile을 구분해 읽습니다. 객체를 만드는 실행 코드는 따로 작성합니다.

키별로 이름을 바꾸고 기존 값 타입을 연결한다UserProfile의name·age·isActive세키는각각별도3노드2간선변환을거쳐getName:string·getAge:number·getIsActive:boolean속성이된다. 세경로간간선이나순차실행은없다. 이그림은타입속성의대응이며실제객체나getter함수를생성하지않는다.namestring키 이름 변환get + 첫 글자 대문자getNamestringagenumber키 이름 변환get + 첫 글자 대문자getAgenumberisActiveboolean키 이름 변환get + 첫 글자 대문자getIsActiveboolean
키별로 이름을 바꾸고 기존 값 타입을 연결한다 · 모바일UserProfile의name·age·isActive세키는각각별도3노드2간선변환을거쳐getName:string·getAge:number·getIsActive:boolean속성이된다. 세경로간간선이나순차실행은없다. 이그림은타입속성의대응이며실제객체나getter함수를생성하지않는다. 모바일에서도 동일 노드와 간선 방향을 유지한다.namestring키 이름 변환get + 첫 글자 대문자getNamestringagenumber키 이름 변환get + 첫 글자 대문자getAgenumberisActiveboolean키 이름 변환get + 첫 글자 대문자getIsActiveboolean

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를 선언해도 실제 객체의 키나 변경 가능성이 자동으로 바뀌지는 않습니다.

타입스크립트 4.1부터는 매핑된 타입의 키를 변환할 수 있는 키 다시 매핑(Key Remapping) 기능이 추가되었습니다.

이는 as 키워드를 사용하여 새로운 키 이름을 정의함으로써 가능합니다.

type MappedTypeWithNewKeys<T> = {
  [P in keyof T as NewKeyType]: T[P];
};
  • NewKeyType: P와 다른 타입 유틸리티를 사용하여 새롭게 생성될 키의 타입.

예시: 속성 키 앞에 get을 붙여 이름 바꾸기

interface UserProfile {
  name: string;
  age: number;
  isActive: boolean;
}

// Getters라는 새로운 타입을 정의합니다.
// 모든 속성 키 앞에 'get'을 붙이고 첫 글자를 대문자로 변환합니다.
type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: T[P];
};

type UserProfileGetters = Getters<UserProfile>;
/*
// UserProfileGetters의 실제 타입은 다음과 같습니다.
type UserProfileGetters = {
  getName: string;
  getAge: number;
  getIsActive: boolean;
};
*/

// 예시 사용:
let userGetters: UserProfileGetters = {
  getName: "Alice",
  getAge: 30,
  getIsActive: true,
};

console.log(userGetters.getName); // Alice

여기서 Capitalize<string & P>는 P (원래 속성 이름)의 첫 글자를 대문자로 변환하는 내장 유틸리티 타입입니다.

string & P는 문자열 키 부분을 선택합니다. 이 예제는 키 이름과 값 타입만 바꾸며 getName도 함수가 아닌 string 속성입니다. 숫자·심벌 키의 문자열화나 런타임 getter 구현을 생성하지 않습니다.

또 다른 예시: 모든 속성을 onChange 접두사와 함께 콜백 함수 타입으로 변환하기

interface FormFields {
  firstName: string;
  lastName: string;
  email: string;
}

type OnChangeCallbacks<T> = {
  [P in keyof T as `onChange${Capitalize<string & P>}`]?: (value: T[P]) => void;
};

type UserFormCallbacks = OnChangeCallbacks<FormFields>;
/*
// UserFormCallbacks의 실제 타입은 다음과 같습니다.
type UserFormCallbacks = {
  onChangeFirstName?: (value: string) => void;
  onChangeLastName?: (value: string) => void;
  onChangeEmail?: (value: string) => void;
};
*/

const formHandler: UserFormCallbacks = {
  onChangeFirstName: (val) => console.log(`First name changed to: ${val}`),
  onChangeEmail: (val) => console.log(`Email changed to: ${val}`),
};

formHandler.onChangeFirstName?.("John"); // First name changed to: John
formHandler.onChangeEmail?.("[email protected]"); // Email changed to: [email protected]

내장 매핑된 유틸리티 타입

타입스크립트는 매핑된 타입을 기반으로 미리 정의된 여러 유용한 유틸리티 타입들(Built-in Mapped Utility Types)을 제공합니다.

  • Partial<T>: T의 모든 속성을 선택적으로 만듭니다. ([P in keyof T]?: T[P])
  • Required<T>: T의 모든 선택적 속성을 필수로 만듭니다. ([P in keyof T]-?: T[P])
  • Readonly<T>: T의 모든 속성을 읽기 전용으로 만듭니다. (readonly [P in keyof T]: T[P])
  • Pick<T, K>: T에서 K에 해당하는 속성들만 선택하여 새로운 타입을 만듭니다. ([P in K]: T[P])
  • Omit<T, K>: T에서 K에 해당하는 속성들을 제외한 새로운 타입을 만듭니다. (Pick<T, Exclude<keyof T, K>>와 유사)
  • Record<K, T>: K의 속성 이름을 가지며, 모든 속성 값이 T 타입인 객체 타입을 만듭니다.

이 유틸리티 타입들은 타입스크립트 개발에서 매우 자주 사용되므로, 그 사용법과 목적을 잘 이해하는 것이 중요합니다.

매핑된 타입을 설계할 때는 키 집합, 속성 수정자, 키 이름 변환, 값 타입 변환을 순서대로 나누면 복잡도를 낮출 수 있습니다.


매핑된 타입은 기존 객체 타입의 속성을 순회하며 새로운 타입을 만드는 기능입니다.

readonly와 ? 수정자, 조건부 타입, 키 다시 매핑을 조합하면 API 응답, 폼 상태, 업데이트 DTO처럼 반복되는 타입 변환을 한곳에서 표현할 수 있습니다.

키 이름과 값 타입을 따로 확인하기

기본 문법의 K, SomeType과 키 재매핑 문법의 NewKeyType은 자리를 설명하는 이름입니다. 그대로 복사한 완성 프로그램이 아니라, 뒤의 keyof T, T[P], 새 이름 식으로 채울 부분입니다. 실제 값을 바꾸는 자바스크립트의 map과 달리 매핑된 타입은 객체를 만들거나 원래 키를 수정하지 않습니다.

위 Getters 선언이 있는 파일에서 아래 타입을 계산해 보세요. name은 문자열 키이고 2는 숫자 리터럴 키입니다.

interface MixedKeys {
  name: string;
  2: boolean;
}

type MixedGetters = Getters<MixedKeys>;

결과에 getName과 get2 중 어떤 속성이 남을까요? getName의 값은 문자열일까요, 문자열을 반환하는 함수일까요? 다음 두 객체 중 어느 것이 계약에 맞는지도 설명해 보세요: { getName: "지수" }, { getName: () => "지수" }.

키와 값의 계산 과정 보기

keyof MixedKeys는 "name" | 2입니다. 문자열 키에서 string & "name"은 "name"이므로 이름을 getName으로 바꾸고, MixedKeys["name"]인 string을 값 타입으로 남깁니다. 숫자 키에서 string & 2는 never이므로 해당 새 키가 만들어지지 않습니다. 결과는 { getName: string }이며 get2는 없습니다.

따라서 { getName: "지수" }가 맞습니다. 함수 객체는 문자열 속성 계약과 맞지 않고, 이 getName은 호출할 함수가 아닙니다. 다음처럼 값을 직접 생성한 뒤 실행 결과를 확인할 수 있습니다.

const mixedGetters: MixedGetters = { getName: "지수" };
console.log(Object.keys(mixedGetters).join(",")); // getName
console.log(mixedGetters.getName);              // 지수
// mixedGetters.getName(); // 예상 오류: string은 호출할 수 없음
// mixedGetters.get2;      // 예상 오류: 이 타입에 해당 속성이 없음

타입 선언이 값을 만든 것이 아니라 객체 리터럴이 getName 속성을 생성했습니다. 값 타입 식을 () => T[P]로 바꾸면 함수 속성 계약이 되지만, 그때도 실제 함수 구현은 별도로 작성해야 합니다.

공식 키 재매핑 설명의 함수형 예시와 비교할 때는 새 키뿐 아니라 콜론 뒤의 값 타입도 확인합니다. 이 절의 Getters는 값 속성을 사용하는 별도 예시입니다.