매핑된 타입으로 나만의 유틸리티 타입 만들기
in으로 키를 순회한다
앞 챕터에서 Partial<T>의 정의를 봤습니다. [P in keyof T]라는 문법이 매핑된 타입(mapped type)의 핵심입니다. in은 자바스크립트의 for...in과 비슷하게, 유니온 타입(여기서는 keyof T가 만든 키들의 유니온)을 하나씩 순회합니다.
직접 하나 만들어보면 감이 빨리 잡힙니다. 모든 속성을 null도 허용하도록 바꾸는 타입입니다.
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};
interface Profile {
name: string;
age: number;
}
type NullableProfile = Nullable<Profile>;
// { name: string | null; age: number | null }[P in keyof T]: T[P] | null을 그대로 읽으면 "T의 각 키 P에 대해, 값의 타입을 원래 타입 또는 null로 한다"가 됩니다. Partial이 ?로 선택적 여부를 바꿨다면, Nullable은 값 자체의 타입을 바꾸는 예시입니다.
수정자를 다루는 ?, readonly, 그리고 -
매핑된 타입에서는 ?(선택적)와 readonly 수정자를 붙이거나 뗄 수 있습니다. 뗄 때는 앞에 -를 붙입니다.
type Required<T> = {
[P in keyof T]-?: T[P];
};
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
interface Config {
readonly host: string;
port?: number;
}
type FullConfig = Required<Config>;
// { readonly host: string; port: number } ← port가 필수가 됐지만 readonly는 그대로
type MutableConfig = Mutable<Config>;
// { host: string; port?: number } ← readonly가 벗겨졌지만 선택적은 그대로Required<T>는 TypeScript 내장 유틸리티 타입이기도 한데, 실제 정의가 -?를 쓴다는 걸 알면 "선택적 수정자를 제거한다"는 동작이 왜 Partial의 정반대인지 명확해집니다. readonly와 ?는 서로 독립적인 수정자라서, Required를 적용해도 readonly는 그대로 남는다는 점을 위 예시에서 확인할 수 있습니다.
as로 키 이름 자체를 바꾸기
TypeScript 4.1부터는 매핑된 타입 안에서 as를 이용해 키 이름을 변형할 수 있게 됐습니다. 흔한 활용은 getter 이름을 자동으로 만드는 것입니다.
type Getters<T> = {
[P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }get${Capitalize<string & P>}는 템플릿 리터럴 타입 문법인데(다음다음 챕터에서 자세히 다룹니다), 여기서는 "키 name을 getName으로 바꾼다"는 동작만 이해하면 됩니다. as 뒤에 원래와 다른 키 표현식을 쓰면, 매핑된 타입은 원본 객체의 키 이름을 그대로 복사하는 대신 새 이름으로 바꿔서 새로운 타입을 만들어냅니다.
as는 특정 키를 아예 제외하는 용도로도 씁니다.
type OmitByValue<T, V> = {
[P in keyof T as T[P] extends V ? never : P]: T[P];
};
interface Form {
name: string;
onSubmit: () => void;
onChange: () => void;
}
type FormData = OmitByValue<Form, (...args: any[]) => any>;
// { name: string } ← 함수 타입인 필드는 전부 제외됐다T[P] extends V ? never : P 부분은 조건부 타입입니다. 조건이 참이면 키를 never로 바꾸는데, 매핑된 타입에서 키가 never가 되면 그 속성은 결과 타입에서 통째로 사라집니다. Omit<T, K>가 "이름을 나열해서 빼는" 방식이라면, 이 패턴은 "값의 타입을 기준으로 빼는" 방식입니다 — Omit으로는 표현할 수 없는 조건입니다.
언제 직접 만들고 언제 내장 유틸리티로 충분한가
Getters<T>나 OmitByValue<T, V> 같은 커스텀 매핑 타입은 화려해 보이지만, 실무 코드베이스에서 남발하면 다른 개발자가 타입 정의를 열어봐야만 실제 타입을 이해할 수 있는 상황이 됩니다. 저는 이 기준을 씁니다 — Partial, Pick, Omit, Record의 조합으로 표현되는 것이면 조합해서 쓰고, 그걸로 안 되는 반복적인 패턴이 팀 안에서 세 번 이상 나타날 때만 이름을 붙여 새 유틸리티 타입을 만듭니다. 한 번 쓰고 말 변형이라면 인라인으로 쓰는 편이 오히려 읽기 쉽습니다.