
조건부 타입(Conditional Types): 타입 레벨에서 if-else
함수의 반환 타입이 입력에 따라 달라져야 했는데, 조건부 타입을 알기 전까지는 any로 때우고 있었다.

함수의 반환 타입이 입력에 따라 달라져야 했는데, 조건부 타입을 알기 전까지는 any로 때우고 있었다.
any를 쓰면 타입스크립트를 쓰는 의미가 없습니다. 제네릭(Generics)을 통해 유연하면서도 타입 안전성(Type Safety)을 모두 챙기는 방법을 정리합니다. infer, keyof 등 고급 기법 포함.

부모에서 전달한 props가 undefined로 나와서 앱이 크래시되는 문제 해결

TypeScript/JavaScript에서 절대 경로 import 설정이 안 될 때의 원인을 '지도와 택시 기사' 비유로 설명합니다. CJS vs ESM 역사적 배경과 모노레포 설정, 팀 컨벤션까지 총정리.

API 요청 상태를 관리할 때 불리언 플래그 여러 개를 쓰시나요? 'impossible state(불가능한 상태)'를 방지하고, if 문 도배를 없애는 Discriminated Unions 패턴.

프로젝트를 하면서 API 함수를 만들 때 자주 마주치는 문제가 있었다. 옵션에 따라 반환 타입이 달라지는 함수를 만들어야 하는데, any를 쓰자니 타입 안정성이 사라지고, 오버로드를 쓰자니 너무 반복적이고 유지보수가 힘들었다.
// 이런 식으로 any로 때우고 있었다
function fetchData(includeDetails: boolean): any {
if (includeDetails) {
return { id: 1, name: "User", details: { age: 30 } };
}
return { id: 1, name: "User" };
}
const basic = fetchData(false); // any 타입... 타입 체크가 안 된다
const detailed = fetchData(true); // 이것도 any...
함수 오버로드로 해결하려 했지만, 경우의 수가 많아지면 코드가 지저분해졌다. 그러다가 조건부 타입을 알게 됐고, "타입도 조건문을 쓸 수 있구나"라는 걸 이해했다. 마치 런타임의 if-else가 컴파일 타임으로 올라간 느낌이었다.
조건부 타입의 기본 문법은 삼항 연산자와 똑같다.
T extends U ? X : Y
"T가 U에 할당 가능하면 X, 아니면 Y"라는 의미다. 처음엔 이게 뭔 소용인가 싶었는데, 제네릭과 결합하면 엄청난 유연성이 생긴다.
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true (리터럴 타입도 string에 할당 가능)
이걸 API 함수에 적용하면:
type User = { id: number; name: string };
type DetailedUser = User & { details: { age: number; email: string } };
function fetchData<T extends boolean>(
includeDetails: T
): T extends true ? DetailedUser : User {
if (includeDetails) {
return {
id: 1,
name: "User",
details: { age: 30, email: "user@example.com" }
} as any; // 구현부에서는 타입 단언이 필요함
}
return { id: 1, name: "User" } as any;
}
const basic = fetchData(false); // User 타입으로 추론됨
const detailed = fetchData(true); // DetailedUser 타입으로 추론됨
console.log(basic.name); // OK
console.log(detailed.details.age); // OK - 타입 안전하게 접근 가능
처음 이게 동작하는 걸 봤을 때, "드디어 any를 버릴 수 있겠다"는 생각이 들었다.
조건부 타입을 공부하다 보면 infer 키워드를 만나게 된다. 처음엔 이게 뭔지 감이 안 왔는데, "타입을 패턴 매칭해서 일부를 추출한다"고 이해하니까 와닿았다.
마치 정규표현식에서 캡처 그룹을 쓰는 것처럼, 타입의 일부를 변수로 캡처하는 거다.
// 함수의 반환 타입을 추출
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "John" };
}
type UserType = ReturnType<typeof getUser>; // { id: number; name: string }
infer R은 "이 위치의 타입을 R이라는 변수로 캡처해줘"라는 의미다. 함수의 반환 타입을 R에 저장하고, 그걸 반환한다.
배열의 요소 타입을 추출할 때도 유용했다:
type ArrayElement<T> = T extends (infer E)[] ? E : never;
type Numbers = ArrayElement<number[]>; // number
type Strings = ArrayElement<string[]>; // string
type Complex = ArrayElement<Array<{ id: number }>>; // { id: number }
Promise의 resolved 타입을 추출하는 건 더 실용적이었다:
type Awaited<T> = T extends Promise<infer U> ? U : T;
async function fetchUser() {
return { id: 1, name: "Alice" };
}
type User = Awaited<ReturnType<typeof fetchUser>>; // { id: number; name: string }
이전엔 async 함수의 반환 타입을 수동으로 정의했는데, Awaited와 ReturnType을 조합하면 자동으로 추론된다.
조건부 타입에 유니온 타입을 넣으면 재밌는 일이 일어난다. 타입이 자동으로 분배(distribute)된다.
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[] (각각 분배되어 적용됨)
// NOT (string | number)[]
처음엔 (string | number)[]가 나올 줄 알았는데, string[] | number[]가 나와서 당황했다. 알고 보니 유니온 타입에 조건부 타입을 적용하면, 각 멤버에 대해 개별적으로 조건부 타입이 적용된다.
이걸 이용하면 TypeScript 내장 유틸리티 타입들을 만들 수 있다:
// NonNullable: null과 undefined 제거
type NonNullable<T> = T extends null | undefined ? never : T;
type A = NonNullable<string | null | undefined>; // string
// Extract: 특정 타입만 추출
type Extract<T, U> = T extends U ? T : never;
type B = Extract<"a" | "b" | "c", "a" | "b">; // "a" | "b"
// Exclude: 특정 타입 제외
type Exclude<T, U> = T extends U ? never : T;
type C = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
이 패턴을 이해하고 나서, TypeScript 공식 문서의 유틸리티 타입들이 어떻게 구현됐는지 다 이해됐다.
조건부 타입이 가장 빛났던 순간은 이벤트 핸들러를 만들 때였다. 이벤트 이름에 따라 페이로드 타입이 달라져야 했다.
type EventMap = {
click: { x: number; y: number };
keypress: { key: string; shift: boolean };
scroll: { deltaY: number };
};
// 이벤트 이름에 따라 핸들러의 매개변수 타입이 결정됨
type EventHandler<E extends keyof EventMap> = (
payload: EventMap[E]
) => void;
class EventEmitter {
on<E extends keyof EventMap>(
event: E,
handler: EventHandler<E>
) {
// 구현...
}
}
const emitter = new EventEmitter();
emitter.on("click", (payload) => {
console.log(payload.x, payload.y); // payload는 { x: number; y: number }로 추론됨
});
emitter.on("keypress", (payload) => {
console.log(payload.key); // payload는 { key: string; shift: boolean }
});
// 타입 에러: 잘못된 페이로드 타입
emitter.on("scroll", (payload) => {
// console.log(payload.key); // 에러: 'key' does not exist on type '{ deltaY: number }'
});
이전에는 이벤트 핸들러를 any로 받거나, 각 이벤트마다 별도의 메서드를 만들어야 했다. 조건부 타입으로 하나의 메서드로 통합하면서도 타입 안정성을 유지할 수 있었다.
실제 프로젝트에서 가장 유용했던 패턴은 API 응답 타입을 옵션에 따라 변경하는 것이었다.
type Post = {
id: number;
title: string;
authorId: number;
};
type Author = {
id: number;
name: string;
email: string;
};
type PostWithAuthor = Post & { author: Author };
interface FetchOptions {
includeAuthor?: boolean;
includeDraft?: boolean;
}
type FetchResult<T extends FetchOptions> =
T["includeAuthor"] extends true
? PostWithAuthor
: Post;
async function fetchPost<T extends FetchOptions>(
id: number,
options: T
): Promise<FetchResult<T>> {
const post = await fetch(`/api/posts/${id}`).then(r => r.json());
if (options.includeAuthor) {
const author = await fetch(`/api/authors/${post.authorId}`).then(r => r.json());
return { ...post, author } as any;
}
return post as any;
}
// 사용 예시
const postOnly = await fetchPost(1, { includeAuthor: false });
console.log(postOnly.title); // OK
const postWithAuthor = await fetchPost(1, { includeAuthor: true });
console.log(postWithAuthor.author.name); // OK - author가 있음을 알고 있음
이 패턴의 핵심은 옵션 객체의 타입에서 반환 타입을 유도한다는 것이다. 런타임 값(includeAuthor: true)이 컴파일 타임 타입 정보로 사용된다.
TypeScript 4.1부터 템플릿 리터럴 타입이 생겼는데, 조건부 타입과 결합하면 문자열 타입을 조작할 수 있다.
// 문자열이 특정 패턴인지 체크
type IsEvent<T> = T extends `on${string}` ? true : false;
type A = IsEvent<"onClick">; // true
type B = IsEvent<"onHover">; // true
type C = IsEvent<"handleClick">; // false
// 이벤트 핸들러 이름에서 이벤트 이름 추출
type ExtractEventName<T> = T extends `on${infer E}` ? Lowercase<E> : never;
type Event1 = ExtractEventName<"onClick">; // "click"
type Event2 = ExtractEventName<"onMouseMove">; // "mousemove"
// 실용적인 예: React 컴포넌트의 props 타입 자동 생성
type EventProps<T extends string> = {
[K in T as `on${Capitalize<K>}`]: (event: { type: K }) => void;
};
type ButtonProps = EventProps<"click" | "hover">;
// {
// onClick: (event: { type: "click" }) => void;
// onHover: (event: { type: "hover" }) => void;
// }
이걸 처음 봤을 때 "타입 시스템이 여기까지 할 수 있구나"라고 놀랐다. 마치 타입 레벨에서 문자열 파싱을 하는 것 같았다.
조건부 타입이 강력하긴 하지만, 모든 경우에 필요한 건 아니다. 과하게 쓰면 오히려 가독성이 떨어진다.
과한 경우:// 너무 복잡한 중첩 조건부 타입
type SuperComplex<T> = T extends string
? T extends `${infer A}_${infer B}`
? A extends "user"
? B extends "admin"
? "UserAdmin"
: "UserNormal"
: "Other"
: "SimpleString"
: never;
이런 타입은 읽기도 힘들고, 디버깅도 어렵다. 이럴 땐 차라리:
// 단순한 타입 매핑이 더 명확함
type RoleMap = {
"user_admin": "UserAdmin";
"user_normal": "UserNormal";
};
type GetRole<T extends keyof RoleMap> = RoleMap[T];
유니온 타입으로 충분한 경우:
// 조건부 타입이 필요 없음
type Status = "loading" | "success" | "error";
function handleStatus(status: Status) {
// 유니온으로 충분히 타입 안전함
}
결국 조건부 타입은 "입력 타입에 따라 출력 타입이 달라질 때" 쓰는 게 맞다고 이해했다.
조건부 타입이 복잡해지면 "왜 이 타입이 이렇게 추론되지?"라는 의문이 생긴다. 이럴 때 쓰는 디버깅 패턴:
// 1. 타입을 단계별로 분해해서 확인
type Step1<T> = T extends string ? true : false;
type Step2<T> = Step1<T> extends true ? "is string" : "not string";
// 2. 타입을 변수로 추출해서 확인
type Test = Step2<"hello">; // "is string"로 추론됨
// 3. extends 조건이 맞는지 직접 체크
type Check = "hello" extends string ? "yes" : "no"; // "yes"
VSCode에서는 타입 위에 마우스를 올리면 최종 타입을 보여주지만, 중간 과정을 보려면 이렇게 단계별로 쪼개야 한다.
디버깅 헬퍼 타입:// 타입을 강제로 평가해서 보여줌
type Evaluate<T> = T extends infer U ? U : never;
type Complex = { a: string } & { b: number };
type Simple = Evaluate<Complex>; // VSCode에서 { a: string; b: number }로 보임
조건부 타입은 타입 레벨의 if-else다. any를 쓰지 않고도 입력에 따라 출력 타입이 달라지는 함수를 만들 수 있게 해준다.
핵심 패턴:
T extends U ? X : Y처음엔 "타입에 조건문을 쓴다"는 개념 자체가 낯설었는데, 한 번 익숙해지니까 API 함수, 이벤트 핸들러, 유틸리티 타입 등 여러 곳에서 any를 없앨 수 있었다. 결국 조건부 타입은 "런타임의 유연성을 타입 안정성과 함께 가져가는 방법"이었다.