이 글은 Chat GPT로 TypeScript를 공부하며 정리한 글입니다.
앞선 글에서는 DeepExtract<T>를 통해
API 응답의 중첩 구조에서 최종 데이터 타입만 추출하는 방법을 배웠다.
이번 글에서는 그보다 한 단계 더 나아가,
“복잡한 타입 구조를 납작하게(Flatten) 펴거나,
여러 타입을 하나로 합치는 UnionToIntersection 패턴”을 다룬다.
이 개념은 타입스크립트의 타입 정규화(normalization) 개념으로,
실무에서 DTO, API 응답, Form 모델 병합 등에 자주 쓰인다.
다음과 같은 중첩 타입이 있다고 하자
type Nested = {
user: {
info: {
name: string;
age: number;
};
active: boolean;
};
};
우리가 원하는 것은 이 타입을 “납작하게” 만들어
{ 'user.info.name': string; 'user.info.age': number; 'user.active': boolean }
형태로 변환하는 것이다.
type Flatten<T, P extends string = ''> = {
[K in keyof T]:
T[K] extends Record<string, any>
? Flatten<T[K], P extends '' ? K & string : `${P}.${K & string}`>
: { [key in P extends '' ? K & string : `${P}.${K & string}`]: T[K] };
}[keyof T];
이제 Flatten<Nested>를 실행해보면
type Result = Flatten<Nested>;
/*
{
'user.info.name': string;
'user.info.age': number;
'user.active': boolean;
}
*/
| 부분 | 의미(수정) |
|---|---|
[K in keyof T] | T의 모든 키를 순회하며 각 키에 대한 조각 타입을 만든다. |
T[K] extends Record<string, any> | 값이 “순수 객체”일 때만 재귀로 들어간다(Date, Array 등 비객체/특수객체 오인 방지). |
P extends '' ? K & string : `${P}.${K & string}` | 경로 접두사 P가 비어있으면 점(.) 없이 키를 쓰고, 아니면 P.key로 이어 붙인다. |
{ [key in ...]: T[K] } | 더 이상 객체가 아니면 경로 → 값 형태의 단일 프로퍼티 객체 조각을 만든다. |
...[keyof T] | 위에서 만든 조각 객체들의 유니온을 꺼낸다(= “병합”이 아니라 유니온임). |
(추가) UnionToIntersection<...> | Flatten이 만든 유니온 조각을 교차(&)로 “병합”하는 별도 단계이다. |
💡 핵심 포인트
Flatten은 재귀 + 템플릿 리터럴 타입으로 구현된다.
핵심은[keyof T]는 병합이 아니라 유니온을 뽑는 단계이고, 진짜 병합은 UnionToIntersection에서 일어난다는 점이다.
Flatten 결과는 종종 유니온 타입의 묶음으로 만들어진다.
예를 들어
type A = { a: number } | { b: string };
이걸 { a: number; b: string } 형태로 합치고 싶을 때가 있다.
→ 바로 UnionToIntersection을 사용한다.
type UnionToIntersection<U> =
(U extends any ? (x: U) => void : never) extends (x: infer I) => void
? I
: never;
type A = { a: number } | { b: string };
type B = UnionToIntersection<A>;
// { a: number } & { b: string } → { a: number; b: string }
U extends any ? (x: U) => void : neverU의 각 멤버에 대해 함수를 하나씩 생성한다.U = A | B → (x: A) => void | (x: B) => void( ... ) extends (x: infer I) => void ? I : neverinfer IIA | B → A & B 로 변환된다.type A = { a: string };
type B = { b: number };
type U = A | B;
// 1단계: (x: A) => void | (x: B) => void
type Step1 = U extends any ? (x: U) => void : never;
// 2단계: 위를 단일 함수 타입과 비교할 때 contravariant → { a: string } & { b: number }
type I = Step1 extends (x: infer P) => void ? P : never;
type Result = I; // { a: string; b: number }
| 개념 | 설명 |
|---|---|
| Covariant(공변) | 반환 타입에서 합쳐질 때는 “유니온”이 된다. |
| Contravariant(반공변) | 매개변수 타입에서 합쳐질 때는 “교차(인터섹션)” 된다. |
| 따라서 | 매개변수 위치((x: U) => void)에 넣고 infer로 꺼내면 A & B 형태가 된다. |
💡 요약
“함수 매개변수의 contravariance(반공변성)”을 이용한
타입스크립트 내부 레벨의 트릭이다.
UnionToIntersection은 “함수 매개변수의 반공변성”을 이용해
유니온(A | B)을 인터섹션(A & B)으로 바꾸는 타입스크립트의 정석적 트릭이다.
Flatten은 유니온 객체의 집합을 만들고,
UnionToIntersection은 그것을 하나의 객체로 합친다.
type Flat<T> = UnionToIntersection<Flatten<T>>;
이제 완성된 버전으로 실행해보자
type Nested = {
user: {
info: {
name: string;
age: number;
};
active: boolean;
};
};
type Result = Flat<Nested>;
/*
{
'user.info.name': string;
'user.info.age': number;
'user.active': boolean;
}
*/
✅ 결과적으로 중첩 객체가 완전히 평탄화되었다.
Form 데이터 관리 시
user.info.name처럼 깊은 필드 이름을 문자열 키로 자동 추출하고 싶을 때 사용된다.
type FormFields = Flat<{
user: {
info: {
name: string;
email: string;
};
address: {
city: string;
zip: number;
};
};
}>;
/*
{
'user.info.name': string;
'user.info.email': string;
'user.address.city': string;
'user.address.zip': number;
}
*/
type FieldName = keyof FormFields;
// "user.info.name" | "user.info.email" | "user.address.city" | "user.address.zip"
이렇게 얻은 FieldName 타입은
React Hook Form이나 Formik 같은 폼 라이브러리에서
필드 자동 완성 키로 활용 가능하다.
type PartialFlatten<T> = Partial<Flat<T>>;
type PartialUser = PartialFlatten<{
profile: {
name: string;
contact: { phone: string; email: string };
};
}>;
/*
{
'profile.name'?: string;
'profile.contact.phone'?: string;
'profile.contact.email'?: string;
}
*/
| 기능 | Flatten | DeepPartial |
|---|---|---|
| 목적 | 중첩된 키를 문자열로 평탄화 | 모든 속성을 선택적으로 변환 |
| 출력 구조 | 'a.b.c' 키를 가진 납작한 객체 | 원래 구조 유지, 모든 필드 optional |
| 활용 예시 | Form field, DTO key mapping | API patch, optional model |
💡 Flatten은 구조를 납작하게, DeepPartial은 구조를 느슨하게 만든다.
예를 들어 REST API에서 “필요한 필드만 요청하는 쿼리”를 만든다고 가정하자.
type FilterFields<T> = (keyof Flat<T>)[];
interface Post {
id: number;
author: { name: string; email: string };
content: { title: string; body: string };
}
const fields: FilterFields<Post> = [
"id",
"author.name",
"content.title",
];
// ✅ 자동 완성 및 타입 검증 모두 작동
Flatten과 UnionToIntersection은
중첩 타입을 “납작하게 평탄화”하고,
여러 타입을 하나로 병합하는 강력한 도구다.
💡 한 문장 요약
“Flatten은 타입을 펼치고, UnionToIntersection은 그 조각을 합친다.”