TypeScript의 Mapped Types와 Conditional Types
1. 개요
TypeScript 프로젝트에서는 종종 유사한 타입들을 여러 개 작성해야 합니다: Order, ReadonlyOrder, OrderUpdate, OrderGetters… 각 타입을 일일이 작성하는 것은 귀찮을 뿐만 아니라, Order가 변경될 때 서로 맞지 않기 쉽습니다.
Mapped types와 conditional types는 이 문제를 정확히 해결합니다. 이들은 기존 타입으로부터 새로운 타입을 생성할 수 있게 해줍니다. 이 글을 읽고 나면, Partial, PickByType, UnwrapPromise 같은 utility type을 직접 작성할 수 있을 것입니다. 또한 이들을 테스트하는 방법과 언제 멈춰야 할지도 알게 될 것입니다.
2. 준비
TypeScript 5.x와 strict 옵션이 필요합니다. 이 글은 타입 테스트에 Vitest를 사용합니다:
npm install -D typescript vitest
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
strict: true는 strictNullChecks도 활성화합니다. 이 글의 일부 결과, 예를 들어 undefined가 string과 맞는지 여부는 이 옵션에 따라 달라집니다.
예제들은 다음 타입을 공유합니다:
export interface Order {
id: string;
customerId: string;
total: number;
paid: boolean;
note?: string;
}
3. Mapped Types
3.1. 기본 구문
Mapped type은 key들의 집합을 순회하며 각 key에 대한 property를 생성합니다:
type ReadonlyOrder = {
readonly [K in keyof Order]: Order[K];
};
const order: ReadonlyOrder = {
id: "o-1",
customerId: "c-1",
total: 250_000,
paid: false,
};
order.paid = true; // 컴파일 오류
설명:
keyof Order는 union"id" | "customerId" | "total" | "paid" | "note"입니다.[K in ...]는 그 union의 각 key를 순회합니다.Order[K]는 indexed access type입니다. propertyK의 타입을 가져옵니다.[K in keyof T]형태의 mapped type을 homomorphic이라고 부릅니다. 이 형태는 원본 modifier를 유지하므로,note는 여전히 optional입니다.
마지막 할당 줄은 오류를 보고합니다:
error TS2540: Cannot assign to 'paid' because it is a read-only property.
위의 예제는 여전히 Order에 하드코딩되어 있습니다. 모든 타입에 재사용하려면 generic 매개변수를 추가하세요:
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };
type OrderUpdate = MyPartial<Order>;
// { id?: string; customerId?: string; total?: number; paid?: boolean; note?: string }
이것이 바로 표준 라이브러리에서 Readonly<T>와 Partial<T>를 정의하는 방식입니다.
3.2. Modifier 추가 및 제거
readonly와 ? 앞에 + 또는 -를 붙일 수 있습니다. +는 기본값이므로 보통 생략합니다. -는 modifier를 제거하는 데 사용됩니다:
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };
type CompleteOrder = Concrete<Order>;
const draft: CompleteOrder = {
id: "o-2",
customerId: "c-9",
total: 99_000,
paid: true,
note: "근무시간 배송",
};
Concrete<Order>에서 property note는 필수가 됩니다. 그 타입은 string이고, 더 이상 string | undefined가 아닙니다. 표준 라이브러리에는 이를 수행하는 Required<T>가 있습니다.
3.3. as로 key 이름 바꾸기
TypeScript 4.1부터, as 절을 사용하여 key의 이름을 바꿀 수 있습니다. 이 방식은 보통 template literal type과 함께 사용됩니다:
interface Customer {
id: string;
name: string;
email: string;
}
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type CustomerGetters = Getters<Customer>;
// {
// getId: () => string;
// getName: () => string;
// getEmail: () => string;
// }
string & K 부분은 필수입니다. keyof T는 number와 symbol도 포함할 수 있지만, Capitalize는 string만 받습니다. 타입 교집합은 문자열이 아닌 key들을 제거합니다.
또한 as를 사용하여 key를 필터링할 수도 있습니다. 표현식이 never를 반환하면, 그 key는 결과에서 제외됩니다:
type WithoutId<T> = {
[K in keyof T as Exclude<K, "id">]: T[K];
};
type NewOrder = WithoutId<Order>;
// { customerId: string; total: number; paid: boolean; note?: string }
여기서 Exclude는 conditional type입니다. 이것이 다음 섹션의 주제입니다.
4. Conditional Types
4.1. extends ? : 구문
Conditional type은 마치 삼항 연산자처럼 작동하지만, 타입 수준에서입니다:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
T extends string은 “T가 string에 할당 가능한가”라는 의미입니다. 이것은 class 상속 관계가 아닙니다.
4.2. 분배성(Distributivity)
T가 naked generic 매개변수이고 union을 전달하면, TypeScript는 조건을 union의 각 멤버에 적용합니다:
type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type C = ToArray<string | number>; // string[] | number[]
type D = ToArrayNonDist<string | number>; // (string | number)[]
두 결과가 완전히 다릅니다. T를 tuple [T]로 감싸면 분배성이 비활성화됩니다.
분배성은 Exclude와 Extract의 기초입니다:
type MyExclude<T, U> = T extends U ? never : T;
type Status = "pending" | "paid" | "cancelled";
type ActiveStatus = MyExclude<Status, "cancelled">; // "pending" | "paid"
TypeScript는 각 멤버를 확인합니다. "cancelled"은 never가 되고, never는 union에서 자동으로 사라집니다.
참고: 분배 conditional type이
never를 받으면never를 반환합니다.never는 빈 union이기 때문입니다. 예를 들어ToArray<never>는never[]이 아니라never입니다.never를 감지해야 하면 tuple 래퍼 형식을 사용하세요:[T] extends [never] ? ... : ....
4.3. infer로 타입 추출하기
infer를 사용하면 조건 분기 내에서 타입의 일부를 “캡처”할 수 있습니다:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
async function fetchOrder(id: string): Promise<Order> {
return { id, customerId: "c-1", total: 0, paid: false };
}
type FetchResult = ReturnType<typeof fetchOrder>; // Promise<Order>
type FetchedOrder = UnwrapPromise<FetchResult>; // Order
type Item = ElementOf<Order[]>; // Order
각 부분의 의미:
Promise<infer U>는 타입 변수U를 선언하고 TypeScript가 자동으로 값을 추론하도록 합니다.- 일치하면,
true분기는U를 사용할 수 있습니다. 일치하지 않으면,UnwrapPromise는 원본T를 반환합니다. - 표준 라이브러리의
ReturnType과Parameters도infer로 작성됩니다.
실제로는 Awaited<T>를 사용해야 합니다. 이것은 Promise<Promise<Order>>와 같은 중첩된 Promise도 처리합니다.
5. 두 기술 결합하기
5.1. 값의 타입으로 property 선택하기
Conditional type을 as 절 안에 넣으면, 값의 타입으로 key를 필터링할 수 있습니다:
type PickByType<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
type OrderNumbers = PickByType<Order, number>; // { total: number }
type OrderStrings = PickByType<Order, string>; // { id: string; customerId: string }
note가 OrderStrings에 나타나지 않는다는 점에 주목하세요. Order["note"]의 타입은 string | undefined이고, strictNullChecks가 활성화되어 있을 때 이 타입은 string에 할당할 수 없습니다. 여기서 T[K]는 naked 매개변수가 아니므로, 조건이 분배되지 않습니다. Union이 하나의 블록으로 평가됩니다.
5.2. 타입이 정해진 event handler
다음은 실제 코드에 가까운 예제입니다. 주문 event 테이블이 있고 handler들의 타입을 생성하려고 합니다:
type OrderEvents = {
created: { orderId: string; total: number };
paid: { orderId: string; paidAt: Date };
cancelled: { orderId: string; reason: string };
};
type EventHandlers<E> = {
[K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void;
};
const handlers: EventHandlers<OrderEvents> = {
onCreated: (p) => console.log(`주문 ${p.orderId} 생성: ${p.total}`),
onPaid: (p) => console.log(`${p.paidAt.toISOString()}에 결제됨`),
onCancelled: (p) => console.log(`취소 사유: ${p.reason}`),
};
handlers.onCreated({ orderId: "o-1", total: 250_000 });
주문 o-1 생성: 250000
각 handler의 매개변수 p는 올바른 타입으로 자동 추론되므로 다시 선언할 필요가 없습니다. OrderEvents에 refunded event를 추가하면, TypeScript는 handlers에 onRefunded가 없다고 즉시 보고합니다.
5.3. 생성된 타입을 위한 runtime 구현
Mapped type은 컴파일 타임에만 존재합니다. 실제 object를 얻으려면 직접 로직을 작성해야 합니다:
export function toGetters<T extends object>(obj: T): Getters<T> {
const result: Record<string, () => unknown> = {};
for (const key of Object.keys(obj) as (keyof T & string)[]) {
const name = `get${key.charAt(0).toUpperCase()}${key.slice(1)}`;
result[name] = () => obj[key];
}
return result as unknown as Getters<T>;
}
왜 타입 캐스팅이 필요한가요? TypeScript는 loop가 정확히 Getters<T> 형태를 만든다는 것을 증명할 수 없습니다. as unknown as 캐스팅은 당신이 이를 보장한다는 약속입니다. 따라서 이 코드는 반드시 테스트를 거쳐야 합니다.
6. Vitest로 타입 테스트하기
Vitest는 타입을 확인하기 위해 expectTypeOf를 가지고 있으며, 일반적인 runtime assertion도 있습니다:
import { describe, it, expect, expectTypeOf } from "vitest";
import type { Order } from "./order";
import { toGetters } from "./getters";
describe("utility types", () => {
it("PickByType은 타입이 맞는 property만 유지합니다", () => {
expectTypeOf<PickByType<Order, number>>().toEqualTypeOf<{ total: number }>();
expectTypeOf<PickByType<Order, string>>()
.toEqualTypeOf<{ id: string; customerId: string }>();
});
it("UnwrapPromise는 Promise 한 단계를 벗깁니다", () => {
expectTypeOf<UnwrapPromise<Promise<Order>>>().toEqualTypeOf<Order>();
expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
});
it("toGetters는 올바른 값을 가진 getter를 반환합니다", () => {
const getters = toGetters({ id: "c-1", name: "An", email: "an@example.com" });
expectTypeOf(getters.getName).returns.toEqualTypeOf<string>();
expect(getters.getName()).toBe("An");
expect(getters.getEmail()).toBe("an@example.com");
// @ts-expect-error getter가 없으면 오류여야 함
getters.getPhone;
});
});
테스트를 타입 체크와 함께 실행합니다:
npx vitest run --typecheck
다음 사항에 주의하세요:
- Runtime에서
expectTypeOf는 아무것도 하지 않습니다. 실제 확인은 컴파일러가 파일을 분석할 때 발생합니다. 따라서--typecheck플래그가 필요하거나, CI에서 추가로npx tsc --noEmit을 실행해야 합니다. // @ts-expect-error는 부정 테스트에 사용됩니다. 아래 줄이 더 이상 오류가 아니면, 예를 들어 누군가 타입을 더 느슨하게 만들었다면, 이 directive 자체가 컴파일 오류가 됩니다.toGetters에 대한 runtime 테스트는 runtime 로직이 약속한 타입에서 벗어난 경우를 잡는 데 도움이 됩니다.
7. 흔한 오류와 언제 사용하면 안 되는지
Template literal에서 string & K를 빠뜨리기. `get${Capitalize<K>}`를 쓰면 오류가 발생합니다. K는 number | symbol일 수 있기 때문입니다.
분배성을 무시하기. IsString<string | number>를 쓰고 false를 기대하지만, 결과는 boolean, 즉 true | false입니다. Union 전체를 하나의 블록으로 평가하려면 tuple로 감싸세요.
any와 함께 extends를 사용할 때 혼동하기. any는 조건이 두 분기 모두를 반환하게 만듭니다. 예를 들어 IsString<any>는 boolean입니다.
Non-homomorphic 타입 매핑에서 modifier를 잃기. Mapped type [K in "id" | "total"]: Order[K]는 Order의 readonly나 ?를 유지하지 않습니다. 오직 [K in keyof T] 형태만 modifier를 유지합니다. 일부 key를 선택하면서도 modifier를 유지하려면 Pick을 사용하세요.
과도한 재귀. DeepPartial<T> 같은 타입은 중첩된 object에 재귀를 적용하며 Type instantiation is excessively deep and possibly infinite 오류를 유발할 수 있습니다. 또한 editor를 현저히 느리게 만듭니다. 더구나 Date, Map과 배열도 object이므로, T[K] extends object 조건은 종종 이들을 잘못 캡처합니다.
참고: “똑똑해 보이기” 위해서만 mapped types와 conditional types를 사용하지 마세요. 타입이 한 곳에서만 사용되고 거의 변경되지 않으면, 직접 작성한 interface가 훨씬 읽기 쉽습니다. 3-4 단계 중첩된 타입의 오류 메시지는 동료가 이해하기 매우 어렵습니다. 자신만의 타입을 작성하기 전에 built-in utility를 우선하세요 (
Partial,Pick,Omit,Record,Exclude,ReturnType,Awaited).
타입이 runtime을 보호한다고 믿기. Mapped type은 API로부터의 데이터를 검증하지 않습니다. JSON이 필드를 빠뜨리면, Getters<Customer>는 컴파일 시에는 “정확”하지만 runtime에는 잘못됩니다. 외부 데이터의 경우, runtime에 검증 단계를 추가해야 합니다.
8. 결론
이 글은 TypeScript에서 타입을 생성하기 위한 두 가지 핵심 도구를 소개했습니다:
- Mapped types,
[K in keyof T]를 사용하여 key를 순회하고,readonly/?를 추가하거나 제거하고,as로 key의 이름을 바꾸거나 필터링할 수 있습니다. - Conditional types,
T extends U ? X : Y를 사용하여 타입 수준에서 분기합니다. 이들은 union에 분배되고infer로 하위 타입을 추출합니다.
두 기술을 결합하면, PickByType이나 EventHandlers 같은 타입을 작성할 수 있습니다. 이런 타입들은 원본 타입이 변경될 때 자동으로 업데이트됩니다. 항상 expectTypeOf와 @ts-expect-error로 타입 테스트를 작성하세요. CI에서 --typecheck을 실행하세요. 그리고 다른 사람들도 읽을 수 있도록 이런 타입들을 충분히 단순하게 유지하세요.