본문으로 건너뛰기
Advertisement

6.3 매핑 타입

매핑 타입이란?

기존 타입의 프로퍼티를 순회하면서 새로운 타입을 만들어내는 타입스크립트의 강력한 기능입니다. 마치 배열의 map 메서드처럼, 객체 타입의 각 키(key)를 변환하거나 값(value) 타입을 바꾸는 것이 가능합니다.

// 기본 형태
type MappedType<T> = {
[K in keyof T]: T[K]; // T의 모든 키를 순회
};

매핑 타입을 사용하면:

  • 반복적인 타입 정의를 제거할 수 있습니다.
  • 기존 타입에서 파생된 타입을 자동으로 생성할 수 있습니다.
  • Partial, Readonly, Pick, Record 같은 내장 유틸리티 타입의 동작 원리를 이해할 수 있습니다.

핵심 개념

1. 기본 매핑 타입 문법

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

// 모든 프로퍼티를 string으로 변환
type Stringified<T> = {
[K in keyof T]: string;
};

type StringifiedUser = Stringified<User>;
// { id: string; name: string; email: string; }

// 값을 함수로 변환
type Getters<T> = {
[K in keyof T]: () => T[K];
};

type UserGetters = Getters<User>;
// { id: () => number; name: () => string; email: () => string; }

2. +/- 수식어 (Modifiers)

+(추가)와 -(제거)로 readonly? 수식어를 제어합니다.

interface Config {
host: string;
port: number;
timeout?: number;
}

// +? : 모든 프로퍼티를 optional로 만들기
type AllOptional<T> = {
[K in keyof T]+?: T[K];
};

// -? : 모든 optional 제거 (Required와 동일)
type AllRequired<T> = {
[K in keyof T]-?: T[K];
};

// +readonly : 모든 프로퍼티를 읽기 전용으로
type AllReadonly<T> = {
+readonly [K in keyof T]: T[K];
};

// -readonly : readonly 제거 (Mutable)
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};

// 조합: readonly 제거 + optional 제거
type WritableRequired<T> = {
-readonly [K in keyof T]-?: T[K];
};

3. as 절 — 키 리매핑 (Key Remapping)

TypeScript 4.1+에서 as 절을 사용하여 키 이름을 변환할 수 있습니다.

// 키 이름을 변환하는 예
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person {
name: string;
age: number;
}

type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number; }

// 특정 키 필터링 (never로 키 제거)
type NonNullableFields<T> = {
[K in keyof T as T[K] extends null | undefined ? never : K]: T[K];
};

interface Profile {
id: number;
nickname: string | null;
bio: string | undefined;
avatarUrl: string;
}

type RequiredProfile = NonNullableFields<Profile>;
// { id: number; avatarUrl: string; }

4. 매핑 타입 + 조건부 타입

// 값 타입에 따라 다른 타입을 적용
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};

// 함수 프로퍼티만 추출
type FunctionProperties<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K];
};

// 비함수 프로퍼티만 추출
type NonFunctionProperties<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
};

interface Service {
name: string;
version: number;
connect(): Promise<void>;
disconnect(): void;
query(sql: string): Promise<unknown[]>;
}

type ServiceConfig = NonFunctionProperties<Service>;
// { name: string; version: number; }

type ServiceMethods = FunctionProperties<Service>;
// { connect: () => Promise<void>; disconnect: () => void; query: (sql: string) => Promise<unknown[]>; }

유틸리티 타입 직접 구현

// Partial<T> — 모든 프로퍼티를 optional로
type MyPartial<T> = {
[K in keyof T]?: T[K];
};

// Required<T> — 모든 optional 제거
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};

// Readonly<T> — 모든 프로퍼티를 읽기 전용으로
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};

// Pick<T, K> — 특정 키만 선택
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};

// Omit<T, K> — 특정 키 제외
type MyOmit<T, K extends keyof T> = MyPick<T, Exclude<keyof T, K>>;

// Record<K, V> — 키-값 쌍으로 구성된 타입
type MyRecord<K extends keyof any, V> = {
[P in K]: V;
};

// 사용 예시
interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}

type PartialTodo = MyPartial<Todo>;
// { id?: number; title?: string; completed?: boolean; createdAt?: Date; }

type TodoPreview = MyPick<Todo, 'id' | 'title'>;
// { id: number; title: string; }

type TodoWithoutDates = MyOmit<Todo, 'createdAt'>;
// { id: number; title: string; completed: boolean; }

코드 예제

예제 1: Getter/Setter 자동 생성

type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};

type GettersAndSetters<T> = Getters<T> & Setters<T>;

interface ProductData {
name: string;
price: number;
inStock: boolean;
}

// 자동으로 생성되는 타입:
// {
// getName: () => string;
// getPrice: () => number;
// getInStock: () => boolean;
// setName: (value: string) => void;
// setPrice: (value: number) => void;
// setInStock: (value: boolean) => void;
// }
type ProductAccessors = GettersAndSetters<ProductData>;

// 실제 구현 팩토리
function createAccessors<T extends object>(data: T): T & GettersAndSetters<T> {
const accessors: any = { ...data };

for (const key of Object.keys(data) as (keyof T)[]) {
const capitalizedKey = String(key).charAt(0).toUpperCase() + String(key).slice(1);
accessors[`get${capitalizedKey}`] = () => data[key];
accessors[`set${capitalizedKey}`] = (value: T[typeof key]) => {
(data as any)[key] = value;
};
}

return accessors;
}

예제 2: EventHandlers 타입

// 이벤트 핸들러 타입 자동 생성
type EventHandlers<T extends Record<string, unknown>> = {
[K in keyof T as `on${Capitalize<string & K>}`]?: (event: T[K]) => void;
};

// DOM-style 이벤트 맵
interface ButtonEventMap {
click: MouseEvent;
focus: FocusEvent;
blur: FocusEvent;
keydown: KeyboardEvent;
}

type ButtonProps = {
label: string;
disabled?: boolean;
} & EventHandlers<ButtonEventMap>;

// {
// label: string;
// disabled?: boolean;
// onClick?: (event: MouseEvent) => void;
// onFocus?: (event: FocusEvent) => void;
// onBlur?: (event: FocusEvent) => void;
// onKeydown?: (event: KeyboardEvent) => void;
// }

// 커스텀 이벤트 에미터 타입
type EventEmitter<Events extends Record<string, unknown>> = {
on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void;
off<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void;
emit<K extends keyof Events>(event: K, data: Events[K]): void;
};

예제 3: DeepReadonly 구현

// 중첩 객체까지 모두 readonly로 만들기
type DeepReadonly<T> = T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;

interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
port: number;
ssl: boolean;
allowedOrigins: string[];
};
}

type ImmutableConfig = DeepReadonly<Config>;

const config: ImmutableConfig = {
database: {
host: 'localhost',
port: 5432,
credentials: {
username: 'admin',
password: 'secret',
},
},
server: {
port: 3000,
ssl: true,
allowedOrigins: ['https://example.com'],
},
};

// 모두 컴파일 오류 발생:
// config.database.host = 'remote';
// config.database.credentials.password = 'new';
// config.server.allowedOrigins.push('other');

실전 예제

실전 1: API 스키마에서 폼 타입 자동 생성

// 서버 스키마 타입
interface CreateUserRequest {
username: string;
email: string;
password: string;
age: number;
acceptTerms: boolean;
}

// 폼 상태: 모든 필드가 string (입력값), 오류 메시지 포함
type FormValues<T> = {
[K in keyof T]: string;
};

type FormErrors<T> = {
[K in keyof T]?: string;
};

type FormTouched<T> = {
[K in keyof T]?: boolean;
};

interface FormState<T> {
values: FormValues<T>;
errors: FormErrors<T>;
touched: FormTouched<T>;
isSubmitting: boolean;
}

type CreateUserFormState = FormState<CreateUserRequest>;

// 초기 상태 생성 헬퍼
function createInitialFormState<T>(keys: (keyof T)[]): FormState<T> {
const values = keys.reduce((acc, key) => {
(acc as any)[key] = '';
return acc;
}, {} as FormValues<T>);

return {
values,
errors: {},
touched: {},
isSubmitting: false,
};
}

// 사용
const formState = createInitialFormState<CreateUserRequest>([
'username', 'email', 'password', 'age', 'acceptTerms'
]);

실전 2: 권한 기반 프로퍼티 접근 제어

type PermissionLevel = 'read' | 'write' | 'admin';

// 권한별로 프로퍼티를 분류하는 데코레이터 타입
type PermissionMap<T> = {
[K in keyof T]: PermissionLevel;
};

type FilterByPermission<T, M extends PermissionMap<T>, P extends PermissionLevel> = {
[K in keyof T as M[K] extends P ? K : never]: T[K];
};

interface Document {
id: number;
title: string;
content: string;
authorId: number;
createdAt: Date;
deletedAt: Date | null;
}

type DocumentPermissions = PermissionMap<Document>;

const documentPermissions: DocumentPermissions = {
id: 'read',
title: 'read',
content: 'read',
authorId: 'read',
createdAt: 'read',
deletedAt: 'admin',
};

// 관리자만 볼 수 있는 필드
type AdminOnlyFields = FilterByPermission<Document, typeof documentPermissions, 'admin'>;
// { deletedAt: Date | null; }

실전 3: 상태 관리 — 액션 자동 생성

// 상태 타입에서 업데이트 액션 타입 자동 생성
type UpdateActions<T, Prefix extends string> = {
[K in keyof T as `${Prefix}/${string & K}`]: {
type: `${Prefix}/${string & K}`;
payload: T[K];
};
}[keyof { [K in keyof T as `${Prefix}/${string & K}`]: unknown }];

interface CartState {
items: string[];
total: number;
couponCode: string | null;
isCheckingOut: boolean;
}

// cart/items, cart/total, cart/couponCode, cart/isCheckingOut 액션 자동 생성
type CartActions = UpdateActions<CartState, 'cart'>;

// 제네릭 리듀서 생성기
type StateReducer<S> = {
[K in keyof S]: (state: S, payload: S[K]) => S;
};

function createReducer<S>(handlers: StateReducer<S>) {
return function reducer(state: S, action: { type: keyof S; payload: any }): S {
const handler = handlers[action.type];
if (handler) {
return handler(state, action.payload);
}
return state;
};
}

고수 팁

팁 1: 매핑 타입 디버깅 — 단계별 전개

// 복잡한 매핑 타입을 단계별로 확인하는 방법
interface ComplexType {
a: string;
b: number | null;
c?: boolean;
readonly d: Date;
}

// Step 1: keyof 결과 확인
type Keys = keyof ComplexType; // 'a' | 'b' | 'c' | 'd'

// Step 2: 인덱스 접근 타입 확인
type AType = ComplexType['a']; // string
type BType = ComplexType['b']; // number | null

// Step 3: 매핑 타입 중간 결과 확인
// type Hover = { [K in keyof ComplexType]: ... } 에서
// 마우스를 올리면 TypeScript가 전개된 결과를 보여줌

// 단일 케이스로 쪼개서 확인
type SingleMapping<T, K extends keyof T> = T[K] extends null | undefined
? never
: T[K];

type BResult = SingleMapping<ComplexType, 'b'>; // number (null 제거됨)

팁 2: 빈 객체 타입 주의사항

// {} 타입은 "null과 undefined를 제외한 모든 값"을 의미
// Record<string, never>와 다름!

type Empty = {};
const a: Empty = 42; // 정상 — number는 {}에 할당 가능
const b: Empty = 'hello'; // 정상 — string도 가능
const c: Empty = { x: 1 }; // 정상 — 객체도 가능
// const d: Empty = null; // 오류!

// 실제 빈 객체를 표현하려면:
type TrulyEmpty = Record<string, never>; // 또는
type EmptyObject = { [key: string]: never };

// 매핑 타입에서 모든 키가 never가 되면 빈 타입
type NeverMapped = {
[K in 'a' | 'b' as never]: string;
}; // {}와 동일 — 경고: 빈 객체 타입이 됨

팁 3: 조건부 키 분배

// 특정 타입의 키만 추출하는 패턴
type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends U ? K : never;
}[keyof T];

interface Mixed {
id: number;
name: string;
email: string;
age: number;
active: boolean;
createdAt: Date;
}

type StringKeys = KeysOfType<Mixed, string>; // 'name' | 'email'
type NumberKeys = KeysOfType<Mixed, number>; // 'id' | 'age'
type BooleanKeys = KeysOfType<Mixed, boolean>; // 'active'

// 특정 타입의 프로퍼티만 선택
type PickByType<T, U> = Pick<T, KeysOfType<T, U>>;

type StringFields = PickByType<Mixed, string>;
// { name: string; email: string; }

정리 표

기능문법설명
기본 매핑[K in keyof T]: T[K]T의 모든 키를 순회하여 새 타입 생성
optional 추가[K in keyof T]?:모든 프로퍼티를 optional로
optional 제거[K in keyof T]-?:모든 optional 제거
readonly 추가readonly [K in keyof T]:모든 프로퍼티를 읽기 전용으로
readonly 제거-readonly [K in keyof T]:읽기 전용 제거 (Mutable)
키 리매핑[K in keyof T as NewKey]:as 절로 키 이름 변환
키 필터링as T[K] extends X ? K : nevernever로 키 제거
조건부 값T[K] extends X ? A : B조건에 따라 값 타입 변환
Partial[K in keyof T]?: T[K]모든 프로퍼티 optional
Required[K in keyof T]-?: T[K]모든 optional 제거
Readonlyreadonly [K in keyof T]: T[K]모든 프로퍼티 readonly
Pick[K in Keys]: T[K]특정 키만 선택
Record[K in Keys]: V키-값 쌍 생성

다음 장에서는 **템플릿 리터럴 타입(6.4)**을 살펴봅니다. 문자열 타입을 조합하고 변환하는 `${T}` 문법, 내장 문자열 유틸리티(Capitalize, Uppercase 등), 그리고 이벤트 이름 자동화 같은 실전 패턴을 학습합니다.

Advertisement