TypeScript 매핑 타입의 원리, 수정자 조작, 키 재매핑(as 절), 그리고 조건부 타입과의 결합 패턴을 실전 예제와 함께 다룹니다.
5장에서 조건부 타입으로 타입 수준의 조건 분기를 다뤘습니다. 이 장에서는 기존 타입을 기반으로 새로운 타입을 생성하는 매핑 타입(Mapped Types) 을 심층 분석합니다. 매핑 타입은 객체 타입의 각 프로퍼티를 순회하며 변환하는 메커니즘으로, TypeScript의 내장 유틸리티 타입 대부분이 이를 기반으로 구현되어 있습니다.
매핑 타입은 in 키워드로 키를 순회하며 새로운 타입을 생성합니다.
type MappedType<T> = {
[K in keyof T]: TransformedType;
};TypeScript의 내장 유틸리티 타입들이 매핑 타입으로 구현된 예를 살펴봅시다.
// Readonly<T> — 모든 프로퍼티를 readonly로
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
// Partial<T> — 모든 프로퍼티를 선택적으로
type Partial<T> = {
[K in keyof T]?: T[K];
};
// Required<T> — 모든 프로퍼티를 필수로
type Required<T> = {
[K in keyof T]-?: T[K];
};
// Pick<T, K> — 특정 프로퍼티만 선택
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};매핑 타입에서 readonly와 ? 수정자를 추가하거나 제거할 수 있습니다.
interface User {
readonly id: string;
name?: string;
email?: string;
}
// readonly 제거
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type MutableUser = Mutable<User>;
// { id: string; name?: string; email?: string }
// ? 제거
type Required<T> = {
[K in keyof T]-?: T[K];
};
type RequiredUser = Required<User>;
// { readonly id: string; name: string; email: string }
// 둘 다 제거
type MutableRequired<T> = {
-readonly [K in keyof T]-?: T[K];
};
type FullUser = MutableRequired<User>;
// { id: string; name: string; email: string }TypeScript 4.1에서 도입된 키 재매핑(Key Remapping) 은 as 절을 사용하여 매핑 과정에서 키 이름을 변환합니다. 이 기능은 매핑 타입의 표현력을 크게 확장합니다.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
email: string;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }as 절에서 never를 반환하면 해당 키가 제외됩니다. 이를 통해 조건에 따라 프로퍼티를 필터링할 수 있습니다.
// 함수 프로퍼티만 추출
type FunctionProperties<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K];
};
// 비함수 프로퍼티만 추출
type DataProperties<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
};
interface UserService {
name: string;
age: number;
greet(): string;
save(): Promise<void>;
}
type UserData = DataProperties<UserService>;
// { name: string; age: number }
type UserMethods = FunctionProperties<UserService>;
// { greet(): string; save(): Promise<void> }// camelCase → snake_case (단순화된 버전)
type CamelToSnake<S extends string> =
S extends `${infer C}${infer Rest}`
? C extends Uppercase<C>
? `_${Lowercase<C>}${CamelToSnake<Rest>}`
: `${C}${CamelToSnake<Rest>}`
: S;
type SnakeCase<T> = {
[K in keyof T as CamelToSnake<string & K>]: T[K];
};
interface ApiResponse {
userId: number;
firstName: string;
lastName: string;
createdAt: Date;
}
type SnakeResponse = SnakeCase<ApiResponse>;
// {
// user_id: number;
// first_name: string;
// last_name: string;
// created_at: Date;
// }// 이벤트 핸들러 이름 생성
type EventHandlers<T> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (
newValue: T[K],
oldValue: T[K]
) => void;
};
interface FormState {
username: string;
password: string;
remember: boolean;
}
type FormHandlers = EventHandlers<FormState>;
// {
// onUsernameChange: (newValue: string, oldValue: string) => void;
// onPasswordChange: (newValue: string, oldValue: string) => void;
// onRememberChange: (newValue: boolean, oldValue: boolean) => void;
// }매핑 타입과 조건부 타입을 결합하면 프로퍼티별로 다른 변환을 적용할 수 있습니다.
// 배열 프로퍼티는 첫 요소 타입으로, 나머지는 그대로
type UnwrapArrays<T> = {
[K in keyof T]: T[K] extends (infer U)[] ? U : T[K];
};
interface QueryResult {
users: { id: string; name: string }[];
count: number;
page: string[];
}
type Unwrapped = UnwrapArrays<QueryResult>;
// {
// users: { id: string; name: string };
// count: number;
// page: string;
// }type OptionalKeys<T> = {
[K in keyof T]-?: undefined extends T[K] ? K : never;
}[keyof T];
type RequiredKeys<T> = {
[K in keyof T]-?: undefined extends T[K] ? never : K;
}[keyof T];
interface Config {
host: string;
port: number;
ssl?: boolean;
timeout?: number;
}
type OKeys = OptionalKeys<Config>; // "ssl" | "timeout"
type RKeys = RequiredKeys<Config>; // "host" | "port"
// 선택적 프로퍼티만 가진 타입
type OptionalOnly<T> = Pick<T, OptionalKeys<T>>;
type ConfigOptionals = OptionalOnly<Config>;
// { ssl?: boolean; timeout?: number }type CreateInput<T> = Omit<T, "id" | "createdAt" | "updatedAt">;
type UpdateInput<T> = Partial<Omit<T, "id" | "createdAt" | "updatedAt">> & {
id: T extends { id: infer ID } ? ID : never;
};
type ListResponse<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
createdAt: Date;
updatedAt: Date;
}
type CreateUserInput = CreateInput<User>;
// { name: string; email: string; role: "admin" | "user" }
type UpdateUserInput = UpdateInput<User>;
// { id: string } & { name?: string; email?: string; role?: "admin" | "user" }
type UserListResponse = ListResponse<User>;
// { items: User[]; total: number; page: number; pageSize: number }type EventListener<T> = (payload: T) => void;
type EventEmitterType<Events extends Record<string, any>> = {
on<K extends keyof Events>(event: K, listener: EventListener<Events[K]>): void;
off<K extends keyof Events>(event: K, listener: EventListener<Events[K]>): void;
emit<K extends keyof Events>(event: K, payload: Events[K]): void;
};
interface AppEvents {
userLogin: { userId: string; timestamp: Date };
userLogout: { userId: string };
pageView: { path: string; referrer?: string };
error: { code: number; message: string };
}
// 사용
declare const emitter: EventEmitterType<AppEvents>;
emitter.on("userLogin", (payload) => {
// payload: { userId: string; timestamp: Date } — 자동 추론
console.log(payload.userId);
});
emitter.emit("error", { code: 500, message: "Server error" }); // OK
emitter.emit("error", { code: "500" }); // Error: string은 number에 할당 불가type DeepPartial<T> = T extends object
? T extends (...args: any[]) => any
? T
: { [K in keyof T]?: DeepPartial<T[K]> }
: T;
interface AppConfig {
server: {
port: number;
host: string;
cors: {
origin: string[];
credentials: boolean;
};
};
database: {
url: string;
pool: {
min: number;
max: number;
};
};
}
type PartialConfig = DeepPartial<AppConfig>;
// 모든 중첩 프로퍼티가 선택적으로 변환됨
// 부분 설정으로 기본값을 덮어쓸 때 유용
function mergeConfig(
defaults: AppConfig,
overrides: DeepPartial<AppConfig>
): AppConfig {
// 깊은 병합 로직...
return { ...defaults, ...overrides } as AppConfig;
}type Builder<T, Built extends Partial<T> = {}> = {
[K in keyof Omit<T, keyof Built> as `set${Capitalize<string & K>}`]: (
value: T[K]
) => Builder<T, Built & Pick<T, K>>;
} & (keyof Omit<T, keyof Built> extends never
? { build: () => T }
: {});
// 모든 필수 프로퍼티가 설정되어야만 build() 메서드가 노출됨Builder 패턴에서 매핑 타입은 "아직 설정되지 않은 프로퍼티"에 대한 setter만 노출하고, 모든 프로퍼티가 설정되면 build() 메서드를 노출하는 정교한 타입 설계를 가능하게 합니다.
매핑 타입은 강력하지만, 복잡한 중첩이나 큰 유니온에 적용하면 컴파일 성능에 영향을 줄 수 있습니다.
// 주의: 큰 유니온에 대한 매핑은 성능 저하를 유발할 수 있음
type HugeUnion = "a" | "b" | "c" | /* ... 수백 개 */ "z";
// 이런 경우 타입 인스턴스화가 조합적으로 폭발할 수 있음
type Problematic = {
[K in HugeUnion]: SomeComplexType<K>;
};매핑 타입의 성능을 개선하려면 불필요한 중첩을 줄이고, 가능하면 TypeScript 내장 유틸리티 타입(Partial, Required, Pick, Omit)을 사용하세요. 내장 타입은 컴파일러에서 특별히 최적화되어 있습니다.
매핑 타입은 기존 타입을 기반으로 새로운 타입을 생성하는 TypeScript의 핵심 메커니즘입니다. 수정자 조작(readonly, ? 추가/제거), 키 재매핑(as 절), 조건부 타입과의 결합을 통해 CRUD 타입 생성, 이벤트 시스템, Builder 패턴 등 복잡한 타입 변환을 선언적으로 표현할 수 있습니다.
다음 장에서는 TypeScript 타입 시스템의 또 다른 강력한 도구인 템플릿 리터럴 타입을 다룹니다. 문자열 패턴 매칭, 문자열 조작 유틸리티, 그리고 매핑 타입과의 결합 패턴을 배웁니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
TypeScript 템플릿 리터럴 타입의 원리, 내장 문자열 조작 유틸리티, 패턴 매칭, 그리고 매핑 타입과의 결합을 통한 실전 패턴을 다룹니다.
TypeScript 조건부 타입의 원리, 분배적 조건부 타입, infer 키워드와의 조합, 그리고 실전 활용 패턴을 깊이 있게 다룹니다.
TypeScript infer 키워드의 고급 활용 패턴, infer extends 구문, 공변/반변 위치에서의 추론, 그리고 실전 타입 추출 패턴을 심층 분석합니다.