TypeScript
TypeScript 타입을 과하게 만들었을 때 겪은 것들
조건부 타입과 mapped type을 남발했다가 빌드 시간이 늘고 팀원 코드 읽기도 힘들어진 경험을 정리했다.
TypeScript를 쓰다 보면 타입 시스템 자체가 재밌어지는 시점이 온다. 제네릭을 쌓고, 조건부 타입을 쌓고, infer를 쓰고 나면 뭔가 완성된 것 같은 기분이 드는데, 나중에 돌아보면 그게 문제의 시작이었다.
과했던 타입의 예시
다음은 실제로 만들었던 유틸리티 타입이다.
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type MaybeArray<T> = T | T[];
type ExtractPromise<T> = T extends Promise<infer U> ? U : T;
// 여기까지는 괜찮다. 문제는 이걸 중첩하기 시작할 때
type HellType<T> = ExtractPromise<DeepPartial<MaybeArray<T>>>;
HellType을 쓰는 쪽에서는 IDE 추론이 멈추다시피 했다. tsc --noEmit 시간도 눈에 띄게 늘었다.
뭐가 문제였나
조건부 타입이 중첩되면 TypeScript 컴파일러가 분기를 열거해야 한다. 분기 수가 지수적으로 늘어나기 때문에 복잡한 제네릭 타입은 컴파일 시간에 직접 영향을 준다. 특히 타입 인자가 넓은 유니온이거나 재귀적일 때 심해진다.
두 번째 문제는 가독성이다. HellType<T>가 결국 뭘 하는 건지 파악하려면 세 개의 유틸리티 타입을 거슬러 올라가야 한다. 새로 보는 사람은 물론이고 만든 본인도 한 달 뒤에는 헷갈린다.
실제로 고친 방향
제네릭 체이닝 대신 명시적인 타입을 정의하는 게 낫다는 결론을 내렸다.
// 전
function process<T>(input: HellType<T>): void { ... }
// 후: 실제로 필요한 형태를 직접 표현
type ProcessInput = {
items?: Partial<Item>[];
single?: Partial<Item>;
};
function process(input: ProcessInput): void { ... }
타입 자체가 문서 역할을 해야 한다. 유틸리티 타입은 반복되는 변환 패턴이 명확할 때 쓰는 것이고, 그냥 "있어 보이니까" 쓰면 코드베이스를 무겁게 만든다.
optional과 null에 대해
optional(?)과 | null을 혼용하면 나중에 처리 코드가 지저분해진다. 한 프로젝트 안에서 어느 쪽을 쓸지 정해두는 게 좋다. 개인적으로는 API 응답처럼 외부 데이터에는 null을, 내부 상태에는 undefined(optional)을 구분해서 쓴다.
// API 응답 - null 명시
type UserResponse = {
name: string;
email: string | null; // 값이 없음을 명시적으로 표현
};
// 내부 옵션 객체 - optional
type Options = {
timeout?: number; // 안 넘기면 기본값 사용
};
npx tsc --noEmit을 빌드 파이프라인에 넣어두면 타입 에러를 배포 전에 잡을 수 있다. 단순한 것 같지만, 이것만으로도 런타임 타입 오류가 꽤 줄었다.