TS의 유틸리티 타입<Record>(feat. Mapped Type)

김유현·2025년 8월 25일

TS

목록 보기
1/2
post-thumbnail

시작하며

회사 프로젝트를 하면서 Record 타입을 자주 마주치곤 했다.
예를 들어 수질 상태 패널 컴포넌트에서는 각 탭의 경고 여부나 차트 옵션을 관리하기 위해 이런 코드가 쓰이고 있었다.

// 탭별 경고 상태
private warnings: Partial<Record<QualityStatusPanelTab, boolean>> = {
  ClAI: false,
  ECAI: false,
  TUAI: false,
  pHAI: false,
};

// 탭별 차트 옵션
private chartOptions: Partial<Record<QualityStatusPanelTab, EChartsOption>> = {
  TotalAI: {},
  ClAI: {},
  ECAI: {},
  TUAI: {},
  pHAI: {},
  Quality: {},
};

겉으로 보기엔 단순히 객체를 정의한 것 같지만 사실 Record<K, T>는 키와 값의 관계를 명확하게 문서화하고 실수할 여지를 줄여주는 도구였다.

나는

  • “왜 하필 Record를 썼을까?”
  • “Mapped Type과 어떤 차이가 있지?”
  • “interface만으로는 안 되는 이유가 뭘까?”

같은 궁금증이 생겼고
그 생각을 정리하면서 이번 글을 쓰게 되었다.


1. Record<K, T>

Record<K, T>는 키(key)의 타입이 K이고 값(value)의 타입이 T인 타입을 생성함.

  • 예시:

    type Person = Record<'name' | 'age', string | number>;
    
    const john: Person = {
        name: 'John Doe',
        age: 30,
    };
    • 위 예시에서 Person 타입은 name 또는 age를 키로 가지며, 값으로는 string 또는 number 타입을 가질 수 있는 객체입니다.

1-1. 단순 인덱스 시그니처({ [key: K]: T })로 대체가 가능할까?

결론부터 말하자면 그렇지 않다.
단순 인덱스 시그니처(Index Signature) 문법은 키(key) 자리에는 string, number, symbol 같은 일반적인 타입만 올 수 있고, 특정 유니온 타입을 직접 넣는 것은 허용되지 않는다.

1-2. Record<K, T> 는 어디서 왔는지에 대해서

Record<K, T>는 사실 Mapped Type을 더 사용하기 쉽게 만들어 놓은 유틸리티 타입(Utility Type)이다.

  • 공식 코드 출처: TypeScript 내장 타입 선언(lib.es5.d.ts)
      /**
       * Construct a type with a set of properties K of type T
       */
      type Record<K extends keyof any, T> = {
          [P in K]: T;
      };
  • 관련 블로그 글

1-3. Mapped Type이란?

  • 기존 타입의 키 집합(keyof T)을 “순회”해 새 속성 집합을 만드는 타입-Level 변환 도구입니다.
  • in, keyof, as(Key Remapping), readonly?, ?(optional) 같은 문법으로 정교하게 가공할 수 있습니다.

맵드 타입은 자바스크립트의 map 함수를 타입에 적용했다고 보면 된다.
아래와 같은 형태의 문법을 사용해야 한다.

{ [ P in K ] : T}
{ [ P in K ]? : T}
{ readonly [ P in K ] : T}
{ readonly [ P in K ]? : T}

1-4. interface 만 사용할 때 Mapped Type 보다 Record의 차이점

  • interface정적 속성 선언인덱스 시그니처({ [key: string]: T })만 허용합니다.
  • 즉, Mapped Type 문법에서는 type 별칭 안에서만 가능하지 interface 본문에서는 허용되지 않습니다.(이 부분은 Record 또한 마찬가지)
  • 그래서 값 선언에서 두 가지 방법으로 사용이 가능하다:
interface CarType {
  sedan: number;
  suv: number;
  truck: number;
}

interface CarInfo {
  brand: string;
  price: number;
}

// 1) Mapped Type 그대로
const CarData: { [K in keyof CarType]: CarInfo[] } = { ... };

// 2) Record 활용 (더 깔끔)
const CarData: Record<keyof CarType, CarInfo[]> = { ... };
  • 두 가지는 사실상 동등한 타입이지만

  • Record<K, T>는 표준 유틸리티 타입으로 바로 의도를 드러내기 때문에 팀/실무 코드에서 더 읽기 좋고 간결합니다.

  • 선택적 속성 여부가 다르다:

    • Mapped Type은 작성 방식에 따라 속성을 선택적으로 만들 수도 있습니다.
    • // 선택적 속성 적용 예시
      type OptionalCarData = { [K in keyof CarType]?: CarInfo[] };
      // => sedan?: CarInfo[], suv?: CarInfo[], truck?: CarInfo[]
    • 반면 Record<K, T>는 단순히
    • type Record<K extends keyof any, T> = { [P in K]: T };
    • 이 정의 그대로라서 모든 키가 필수(required) 입니다.

    즉,

Record<keyof CarType, CarInfo[]>
sedan, suv, truck 모두 필수

{ [K in keyof CarType]?: CarInfo[] }
// => sedan?: CarInfo[], suv?: CarInfo[], truck?: CarInfo[]
sedan, suv, truck 모두 선택적


2. 결론

  • interface에서는 Mapped Type 문법을 직접 쓸 수 없으므로
    값이나 함수 시그니처에서는 Record<...>를 쓰는 게 가장 깔끔합니다.
  • Mapped Type옵션 속성 여부나 고급 변형을 제어할 수 있고
    Record단순 딕셔너리 구조만 표현하기 때문에
    “모든 키가 필수이고 값 타입이 동일한” 상황에서는 Record가 더 직관적입니다.
profile
FRONTEND DEVELOPER

0개의 댓글