TypeScript 5.x 조건부 타입으로 API 응답 타입 개선하기
문제 상황
프로젝트에서 API 응답을 처리하는 공통 유틸을 작성하던 중, 성공/실패 상태에 따라 응답 구조가 달라지는 문제가 있었다.
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string };
function handleResponse<T>(response: ApiResponse<T>) {
if (response.success) {
// response.data에 접근 가능하지만
// 매번 타입 가드 필요
}
}
매번 타입 가드를 작성하는 것도 번거롭고, 실수로 체크를 빠뜨리면 런타임 에러가 발생했다.
해결 방법
조건부 타입과 제네릭을 조합해 호출 시점에 타입이 결정되도록 개선했다.
type ApiResult<S extends boolean, T> = S extends true
? { success: true; data: T }
: { success: false; error: string };
async function fetchUser<S extends boolean>(
id: string,
throwOnError?: S
): Promise<ApiResult<S extends true ? true : false, User>> {
try {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return { success: true, data } as any;
} catch (error) {
if (throwOnError) throw error;
return { success: false, error: error.message } as any;
}
}
// 사용 시 타입이 명확하게 추론됨
const result1 = await fetchUser('123', true); // data 타입 확정
const result2 = await fetchUser('123', false); // error 가능성 포함
더 나은 접근
실제로는 discriminated union이 더 실용적이었다. 조건부 타입은 과한 추상화였고, 간단한 타입 가드가 더 명확했다.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
return result.ok;
}
복잡한 타입보다는 팀원이 이해하기 쉬운 코드가 우선이라는 걸 다시 느꼈다.