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 : never | never로 키 제거 |
| 조건부 값 | T[K] extends X ? A : B | 조건에 따라 값 타입 변환 |
| Partial | [K in keyof T]?: T[K] | 모든 프로퍼티 optional |
| Required | [K in keyof T]-?: T[K] | 모든 optional 제거 |
| Readonly | readonly [K in keyof T]: T[K] | 모든 프로퍼티 readonly |
| Pick | [K in Keys]: T[K] | 특정 키만 선택 |
| Record | [K in Keys]: V | 키-값 쌍 생성 |
다음 장에서는 **템플릿 리터럴 타입(6.4)**을 살펴봅니다. 문자열 타입을 조합하고 변환하는 `${T}` 문법, 내장 문자열 유틸리티(Capitalize, Uppercase 등), 그리고 이벤트 이름 자동화 같은 실전 패턴을 학습합니다.