TypeScript: Mapped Types를 이용한 동적 객체 타입 빌드 기법

TypeScript Mapped Types 동적 객체 타입 정의 및 실무 활용 가이드
3초 요약: 이렇게 고치세요
  • 원인: 기존 타입에서 속성 몇 개만 바뀐 파생 타입을 매번 손으로 다시 선언하면 원본이 바뀔 때마다 따로 수정해야 해서 동기화가 깨지기 쉬움
  • 해결: {'{'} [P in keyof T]: T[P] {'}'} 형태의 Mapped Type으로 원본 타입을 순회하며 파생 타입을 동적으로 빌드
  • 주의: readonly·? 수정자는 -readonly·-?로 제거할 수도 있음 — 필요 이상으로 남발하면 오히려 어떤 타입이 원본인지 추적하기 어려워짐
type CustomReadonly<T> = { readonly [P in keyof T]: T[P] };
↓ 왜 이런 문제가 나는지 원리 보기

TypeScript를 사용하다 보면 기존 타입을 기반으로 거의 유사하지만 일부 속성만 변경된 새로운 타입을 정의해야 할 때가 많습니다. 예를 들어, 모든 속성을 선택적(Optional)으로 만들거나, 읽기 전용(Readonly)으로 변경하거나, 특정 접두사/접미사를 붙여 API 응답 형식에 맞춰 변환하는 작업 등입니다.

이때 매번 손으로 새로운 타입을 선언하면 원본 타입이 바뀌었을 때 파생 타입들이 자동으로 따라 바뀌지 않아, 어딘가 한 군데를 빼먹은 채로 컴파일은 통과해버리는 상황이 생기기 쉽습니다. Mapped Types(맵드 타입)는 기존 타입의 키를 순회(Map)하며 새로운 객체 타입을 동적으로 빌드해서 이 문제를 해결합니다.


Mapped Types vs 일반 타입 정의 (핵심 차이점)

Mapped Types와 일반 인터페이스 정의의 명확한 차이점을 파악해 보겠습니다.

일반 인터페이스 정의

  • 정적 구조: 모든 키와 타입을 사전에 명시해야 합니다.
  • 수동 동기화: 원본 스키마가 변경되면 파생 타입도 일일이 직접 수정해야 하므로 유지보수 부하가 큽니다.
  • 수정자 재사용 불가: Readonly, Optional 등의 변환 작업을 타입마다 독립적으로 반복 작성해야 합니다.

Mapped Types 정의

  • 동적 구조: 기존 타입의 키 집합(keyof T)을 순회하며 동적으로 타입을 빌드합니다.
  • 자동 동기화: 단일 원본 타입의 수정사항이 파생 타입에 실시간으로 반영됩니다.
  • 강력한 수정자 제어: +, - 기호를 통해 선택적(?) 및 읽기 전용(readonly) 제어권을 유연하게 제어합니다.

실무에서 자주 쓰는 패턴

예전에 외주로 맡았던 관리자 페이지 프로젝트에서, 폼 하나를 놓고 '전체 등록용 타입'과 'PATCH 수정용 타입'을 각각 따로 선언해 쓰고 있었습니다. 필드가 하나 추가될 때마다 두 타입에 똑같이 손을 대야 했고, 한쪽만 고치고 넘어가서 타입은 통과하는데 실제로는 필드가 누락되는 버그가 몇 번 있었습니다.

등록용 타입 하나만 원본으로 두고, 수정용 타입은 {'{'} [P in keyof T]?: T[P] {'}'} 형태의 Mapped Type으로 파생시키도록 바꾸고 나서는 필드를 추가할 때 원본 타입 한 곳만 고치면 됐습니다. 이후로는 두 타입이 서로 어긋나는 문제 자체가 생기지 않았습니다.


실무형 TypeScript 예제

Mapped Types가 작동하는 가장 직관적인 예제 코드를 소개합니다. 아래 예제에서는 기본 사용자 인터페이스를 기반으로, 모든 속성을 읽기 전용으로 변환하거나 필수 속성으로 강제하는 유틸리티 타입을 빌드합니다.

// 1. 기준이 되는 원본 데이터 모델 정의
interface User {
  id: number;
  name: string;
  email: string;
  age?: number; // 선택적 속성
}

// 2. Mapped Types를 이용한 커스텀 Readonly 유틸리티 타입 생성
// keyof T를 순회하며 모든 속성에 readonly 수정자를 동적으로 부여합니다.
type CustomReadonly<T> = {
  readonly [P in keyof T]: T[P];
};

// 3. Mapped Types를 이용하여 선택적 속성을 모두 제거하고 필수로 강제하는 타입 생성
// '-?' 문법을 사용하면 기존의 optional(?) 수정자를 동적으로 제거할 수 있습니다.
type StrictUser<T> = {
  [P in keyof T]-?: T[P];
};

// 4. 실제 적용 및 검증
const readonlyUser: CustomReadonly<User> = {
  id: 1,
  name: "홍길동",
  email: "hong@example.com",
};

// 테스트: readonly 속성이므로 수정 시 에러가 발생합니다.
// readonlyUser.name = "김철수"; // Error: Cannot assign to 'name' because it is a read-only property.

const strictUser: StrictUser<User> = {
  id: 2,
  name: "홍길동",
  email: "hong@example.com",
  age: 30, // 원본에서 선택적(?)(Optional)이었던 age가 필수(Required)로 강제되었습니다. (누락 시 에러 발생)
};

정리하면 readonly는 값을 불변으로 지키고 싶을 때, ?는 PATCH 요청처럼 일부 필드만 보낼 때, -readonly는 불변 객체를 다시 가변으로 풀어야 할 때, -?는 누락되면 안 되는 값을 강제할 때 씁니다.

추가 추천 자료 및 공식 레퍼런스

Mapped Types의 응용 기법(Key Remapping 등)은 공식 문서에서 더 자세히 확인할 수 있습니다.

호마다의 웹개발 팁

Mapped Types도 결국 제네릭이 타입을 어떻게 변수처럼 다루는지 아는 데서 출발합니다. 기초가 헷갈리신다면 [호마다의 IT 개발 입문 블로그] 의 제네릭 기초 글도 참고해보세요.

댓글

이 블로그의 인기 게시물

TypeScript: Unknown vs Any 타입 차이점과 안전한 타입 가드(Type Guard)

Next.js 하이드레이션 오류 원인과 해결법 완벽 정리

Next.js App Router에서 getServerSideProps 대체 및 데이터 페칭법