타입 매직: TypeScript로 복잡한 로직 해결하기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
끊임없이 진화하는 웹 개발 환경에서 JavaScript는 여전히 지배적인 힘을 발휘하고 있습니다. 그러나 JavaScript의 동적인 특성은 유연성을 제공하는 동시에 런타임이 되어서야 감지하기 어려운 미묘한 버그를 야기할 수 있습니다. 이때 JavaScript의 상위 집합인 TypeScript가 등장하여 강력한 정적 타이핑을 제공합니다. TypeScript는 일반적인 타입 관련 오류를 방지하는 능력으로 자주 칭찬받지만, 그 타입 시스템은 단순한 기본 타입 검사보다 훨씬 강력합니다. 컴파일 타임에 정교한 계산 환경을 제공하여 "타입 체조"를 수행할 수 있게 해주는데, 이는 타입 시스템 자체를 사용하여 복잡한 로직 문제를 해결하는 고급 기법입니다. 이 접근 방식은 코드의 안정성과 유지보수성을 향상시킬 뿐만 아니라 잠재적인 런타임 오류를 컴파일 타임 보장으로 변환하여 개발자 경험을 크게 향상시킵니다. 이 글에서는 TypeScript의 고급 타입 기능을 활용하여 복잡한 로직 문제를 해결하고, 단순한 타입 선언을 넘어 타입 시스템의 힘을 진정으로 활용하는 방법을 자세히 살펴보겠습니다.
타입의 캔버스: 빌딩 블록 이해하기
구체적인 예시로 들어가기 전에, 타입 체조에 사용할 핵심 타입 시스템 기능에 대한 공통된 이해를 확립합시다. 이것들은 복잡한 로직을 순전히 타입 영역에서 표현할 수 있게 해주는 기본 도구입니다.
-
조건부 타입 (
T extends U ? X : Y
): 타입 레벨 로직의 기반입니다. JavaScript의if/else
문과 유사하게 타입 관계를 기반으로 분기할 수 있게 해줍니다. 이는 입력에 따라 다르게 동작하는 타입을 생성하는 데 중요합니다.type IsString<T> = T extends string ? true : false; type R1 = IsString<'hello'>; // true type R2 = IsString<123>; // false
-
Infer 키워드 (
infer U
): 조건부 타입 내에서 사용되는infer
를 통해 다른 타입의 위치에서 타입을 추출하고, 그 추출된 타입을 조건부 타입의true
분기에서 사용할 수 있습니다. 이는 타입의 구조 분해와 같습니다.type GetArrayElement<T> = T extends (infer Element)[] ? Element : never; type R3 = GetArrayElement<number[]>; // number type R4 = GetArrayElement<string>; // never
-
맵드 타입 (
{ [P in K]: T }
): 이 타입들은 다른 타입의 속성을 반복하고 변환할 수 있게 해줍니다. 모든 속성을 선택 사항이나 읽기 전용으로 만드는 것과 같이 기존 타입을 기반으로 새 타입을 생성하는 데 필수적입니다.type ReadonlyProps<T> = { readonly [P in keyof T]: T[P]; }; interface User { name: string; age: number; } type ImmutableUser = ReadonlyProps<User>; // { readonly name: string; readonly age: number; }
-
템플릿 리터럴 타입 (
type EventName<T extends string> = `on${Capitalize<T>}Change`; type ClickEvent = EventName<'click'>; // "onClickChange"
-
재귀적 타입: 직접적인 키워드는 아니지만, 타입이 자신을 참조할 수 있다는 능력은 임의로 중첩된 구조나 시퀀스를 처리하는 데 기본적입니다. 이는 종종 조건부 타입과 튜플 타입을 통해 달성됩니다.
이 도구들은 창의적으로 결합될 때 강력한 컴파일 타임 계산 엔진을 잠금 해제합니다.
복잡한 로직 다루기: 실용적인 예시
이러한 개념이 어떻게 중간 정도의 복잡한 로직 문제를 해결하는지 설명해 보겠습니다. 주어진 타입에서 깊이 중첩된 모든 객체 경로를 추출하여 점으로 구분된 문자열로 표현하는 타입을 만드는 것입니다. 이는 폼, API 응답 또는 구성 객체를 다룰 때 종종 필요한 요구 사항으로, 경로 문자열을 사용하여 속성을 참조해야 할 때 유용합니다.
다음 입력 타입을 고려해봅시다:
interface Data { user: { id: number; address: { street: string; city: string; }; }; products: Array<{ name: string; price: number; }>; isActive: boolean; }
우리는 다음과 같은 결과를 생성하는 타입을 원합니다: "user" | "user.id" | "user.address" | "user.address.street" | "user.address.city" | "products" | "products[number]" | "products[number].name" | "products[number].price" | "isActive"
이 문제는 타입의 구조를 순회하고, 객체/배열/기본 타입을 처리하며, 새로운 문자열 리터럴 타입을 구성해야 합니다.
다음은 TypeScript 타입 솔루션입니다:
type Primitive = string | number | boolean | symbol | null | undefined; type PathImpl<T, Key extends keyof T> = Key extends string ? T[Key] extends Primitive ? `${Key}` : T[Key] extends Array<infer U> ? Key extends string ? `${Key}` | `${Key}[${number}]` | `${Key}[${number}].${DeepPaths<U>}` : never : `${Key}` | `${Key}.${DeepPaths<T[Key]>}` : never; type DeepPaths<T> = T extends Primitive ? never : { [Key in keyof T]: PathImpl<T, Key> }[keyof T]; // Data 인터페이스로 테스트: type DataPaths = DeepPaths<Data>; /* "user" | "user.id" | "user.address" | "user.address.street" | "user.address.city" | "products" | "products[number]" | "products[number].name" | "products[number].price" | "isActive" */
이 타입 레벨 로직을 자세히 살펴보겠습니다:
Primitive
: 기본 데이터 타입을 식별하는 도우미 타입입니다. 여기에 도달하면 재귀를 중지하고 싶습니다.DeepPaths<T>
: 이것이 우리의 진입점입니다.- 먼저
T
가Primitive
인지 확인합니다. 그렇다면 더 이상의 경로는 없으므로never
를 반환합니다. - 그렇지 않으면 맵드 타입
{ [Key in keyof T]: PathImpl<T, Key> }
을 생성합니다. 이는T
의 각 키를 반복하고 각 키-값 쌍에PathImpl
을 적용합니다. - 마지막으로
[keyof T]
를 사용하여 타입 객체를 단일 문자열 리터럴 유니온으로 평탄화합니다.
- 먼저
PathImpl<T, Key extends keyof T>
: 이 타입은 단일 키와 해당 값에 대한 로직을 처리합니다.Key extends string ? ... : never
: 문자열 키만 처리하도록 합니다.T[Key] extends Primitive ?
${Key}: ...
:T[Key]
의 값이Primitive
이면 경로가 현재 키로 간단히 끝납니다(예: `