[TypeScript] Flatten과 UnionToIntersection을 활용한 타입 평탄화(정규화)

Chan의 기술 블로그·2025년 11월 11일

TypeScript

목록 보기
8/10

이 글은 Chat GPT로 TypeScript를 공부하며 정리한 글입니다.

복잡한 타입을 평탄화(Flatten)하고 합치기 — UnionToIntersection & Flatten 패턴

앞선 글에서는 DeepExtract<T>를 통해
API 응답의 중첩 구조에서 최종 데이터 타입만 추출하는 방법을 배웠다.

이번 글에서는 그보다 한 단계 더 나아가,
“복잡한 타입 구조를 납작하게(Flatten) 펴거나,
여러 타입을 하나로 합치는 UnionToIntersection 패턴”을 다룬다.

이 개념은 타입스크립트의 타입 정규화(normalization) 개념으로,
실무에서 DTO, API 응답, Form 모델 병합 등에 자주 쓰인다.

Flatten — 중첩된 객체를 평탄화하기

문제 상황

다음과 같은 중첩 타입이 있다고 하자

type Nested = {
  user: {
    info: {
      name: string;
      age: number;
    };
    active: boolean;
  };
};

우리가 원하는 것은 이 타입을 “납작하게” 만들어
{ 'user.info.name': string; 'user.info.age': number; 'user.active': boolean }
형태로 변환하는 것이다.

1단계 — 기본 구조 만들기

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에서 일어난다는 점이다.

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 }

작동 원리

  1. U extends any ? (x: U) => void : never
    • 유니온 U의 각 멤버에 대해 함수를 하나씩 생성한다.
    • 예: U = A | B(x: A) => void | (x: B) => void
  2. ( ... ) extends (x: infer I) => void ? I : never
    • 위 함수들의 유니온이 단일 함수 타입에 할당 가능한지 비교할 때, TS의 반공변성 규칙 때문에 매개변수 타입들이 교차(&) 된다.
  3. infer I
    • 그 교차된(intersected) 타입을 추론한다.
  4. I
    • 즉, A | BA & 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 결합

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 필드 네이밍 자동 추론

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 같은 폼 라이브러리에서
필드 자동 완성 키로 활용 가능하다.

확장 예시 — PartialFlatten (선택적 키 적용)

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 vs DeepPartial 비교

기능FlattenDeepPartial
목적중첩된 키를 문자열로 평탄화모든 속성을 선택적으로 변환
출력 구조'a.b.c' 키를 가진 납작한 객체원래 구조 유지, 모든 필드 optional
활용 예시Form field, DTO key mappingAPI patch, optional model

💡 Flatten은 구조를 납작하게, DeepPartial은 구조를 느슨하게 만든다.

응용 — API 필드 필터링 시스템

예를 들어 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은 그 조각을 합친다.”

profile
퍼블리셔에서 프론트앤드로 전향하기

0개의 댓글