TypeScript 고급 타입 패턴: 실무에서 바로 쓰는 타입 설계 전략
저희 팀이 외주 프로젝트를 진행하다 보면 TypeScript를 "쓰고는 있지만 제대로 활용하지 못하는" 코드를 자주 마주칩니다. any가 난무하거나, 런타임에서야 타입 오류가 터지거나, API 응답 타입과 실제 데이터가 따로 노는 경우들입니다. TypeScript는 단순히 타입을 붙이는 도구가 아닙니다. 잘 설계된 타입은 그 자체로 문서가 되고, 버그를 컴파일 타임에 잡아주는 안전망이 됩니다.
이 글에서는 저희 팀이 실제 프로젝트에서 반복적으로 활용하는 고급 타입 패턴들을 정리합니다. 이론보다는 "이 상황에서 이렇게 썼더니 좋았다"는 현장 경험 위주로 풀어보겠습니다.
Discriminated Union으로 상태 타입 설계하기
가장 자주 쓰이면서도 가장 많이 잘못 쓰이는 패턴입니다. API 응답이나 UI 상태를 표현할 때 loading, error, success 상태를 하나의 타입으로 묶어야 하는 경우가 많습니다.
흔히 이렇게 작성합니다.
type FetchState = {
isLoading: boolean;
error: string | null;
data: User[] | null;
};
이 방식의 문제는 isLoading: true이면서 data가 존재하는 모순된 상태가 타입 수준에서 허용된다는 점입니다. Discriminated Union을 사용하면 불가능한 상태를 타입으로 원천 차단할 수 있습니다.
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: string }
| { status: "success"; data: T };
status 필드를 기준으로 타입이 좁혀지기 때문에 switch 문이나 if 분기 안에서 TypeScript가 자동으로 올바른 타입을 추론합니다. 컴포넌트나 서비스 로직에서 잘못된 상태 조합을 실수로 만들 수 없게 됩니다.
Mapped Type으로 폼 유효성 타입 자동 생성하기
백오피스나 어드민 패널을 만들 때 폼이 많이 등장합니다. 폼 필드마다 에러 메시지 타입을 별도로 정의하다 보면 원본 데이터 타입과 에러 타입이 금방 어긋납니다. 필드가 추가될 때마다 두 곳을 동시에 수정해야 하는 번거로움도 생깁니다.
이런 상황에 Mapped Type이 정확히 들어맞습니다.
type FormErrors<T> = {
[K in keyof T]?: string;
};
type UserForm = {
email: string;
password: string;
name: string;
};
type UserFormErrors = FormErrors<UserForm>;
// { email?: string; password?: string; name?: string; }
UserForm에 필드가 추가되면 UserFormErrors도 자동으로 맞춰집니다. 원본 타입 하나만 관리하면 파생 타입들이 자동으로 동기화되는 구조를 만들 수 있습니다.
여기서 한 발 더 나아가 Partial, Required, Pick, Omit 같은 유틸리티 타입과 조합하면 훨씬 더 정교한 타입 계층을 만들 수 있습니다.
Conditional Type으로 API 응답 타입 분기 처리하기
외부 API나 내부 마이크로서비스를 연동할 때, 성공 응답과 실패 응답의 구조가 다른 경우가 많습니다. 이를 제네릭과 Conditional Type으로 한 번에 표현할 수 있습니다.
type ApiResponse<T> = T extends void
? { success: true }
: { success: true; data: T } | { success: false; error: string };
반환값이 없는 API(void)는 data 없이 성공 여부만 반환하고, 반환값이 있는 API는 data 필드를 포함합니다. 타입 하나로 다양한 API 응답 패턴을 일관되게 모델링할 수 있습니다.
실제로 저희 팀은 이 패턴을 공통 API 클라이언트 레이어에 적용해서, 각 엔드포인트마다 응답 타입을 따로 작성하는 수고를 크게 줄이고 있습니다.
Template Literal Type으로 이벤트 네임 타입 만들기
이벤트 기반 시스템이나 분석 트래킹 코드를 작성할 때, 이벤트 이름을 문자열 리터럴로 관리하면 오타나 규칙 위반을 런타임에야 알게 됩니다.
Template Literal Type을 활용하면 이벤트 이름 규칙 자체를 타입으로 표현할 수 있습니다.
type Screen = "home" | "product" | "cart" | "checkout";
type Action = "view" | "click" | "submit";
type AnalyticsEvent = `${Screen}_${Action}`;
// "home_view" | "home_click" | "home_submit" | "product_view" | ...
정의된 조합 외의 이벤트 이름은 컴파일 타임에 오류가 발생합니다. 이벤트 네이밍 컨벤션을 팀 전체가 자동으로 따르게 되는 구조입니다. 새로운 화면이나 액션이 추가될 때 유니언만 수정하면 전체 이벤트 타입이 자동 갱신됩니다.
타입 설계는 곧 시스템 설계입니다
저희가 이런 패턴들을 공들여 적용하는 이유는 단순히 "코드가 깔끔해서"가 아닙니다. 타입이 잘 설계된 코드는 팀원이 바뀌어도, 시간이 지나도, 기능이 추가되어도 흔들리지 않습니다. 외주 프로젝트 특성상 인수인계가 잦고, 초기 개발 이후 운영 단계에서 다른 개발자가 코드를 이어받는 경우가 많습니다. 그 상황에서 타입이 버텨주는 코드는 유지보수 비용을 눈에 띄게 낮춰줍니다.
처음부터 완벽한 타입 설계를 할 필요는 없습니다. 시작은 any 없이 기본 타입을 제대로 쓰는 것, 다음은 상태 타입을 Discriminated Union으로 정리하는 것, 그다음은 반복되는 타입 변환을 Mapped Type으로 추상화하는 순서로 점진적으로 발전시켜 나가면 됩니다.
픽셀앤로직 기술팀은 늘 이 원칙으로 개발합니다.


