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;
}

복잡한 타입보다는 팀원이 이해하기 쉬운 코드가 우선이라는 걸 다시 느꼈다.