TypeScript 제네릭으로 API 응답 타입 안전하게 다루기
문제
API를 호출할 때마다 응답 타입을 매번 as로 단언하고 있었다.
const res = await fetch('/api/users');
const data = await res.json() as User[];
이러면 실제 응답 구조가 바뀌어도 타입 에러가 안 나서 런타임에 터진다.
해결
제네릭 래퍼 함수를 하나 만들면 된다.
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
사용할 때는 이렇게 쓴다.
interface User {
id: number;
name: string;
email: string;
}
// data의 타입이 ApiResponse<User[]>로 추론된다
const { data: users } = await fetchApi<User[]>('/api/users');
// data의 타입이 ApiResponse<User>로 추론된다
const { data: user } = await fetchApi<User>('/api/users/1');
핵심 포인트
as타입 단언은 컴파일러를 속이는 거다. 제네릭은 타입 시스템이 직접 추론하게 만든다. 런타임 안전성 차이가 크다.- 에러 응답도 제네릭으로 통일하고 싶으면 유니온 타입을 쓰면 된다:
Promise<ApiResponse<T> | ApiError>. - Axios를 쓴다면 이미
axios.get<T>()처럼 제네릭을 지원한다. 위 패턴은 fetch API를 쓸 때 동일한 경험을 만들어주는 거다.