TypeScript 타입 패턴 (3) — 기타 자주 쓰이는 패턴

park·2026년 7월 16일

면접과 실무에서 빈도 높은 세 가지. 특히 마지막 unknown과 API 에러 처리는 실전에서 가장 자주 마주치는 주제다.

이 글에서 다루는 것:

  1. 제네릭 + 제약(extends)
  2. 함수 오버로드 vs 유니온
  3. unknown vs any — 그리고 API 응답/에러 타입 정의

1. 제네릭 + 제약(extends)

언제 쓰나: 여러 타입에 똑같이 동작하는 함수/타입을 만들되, 입력에 따라 반환 타입이 달라져야 할 때. 그리고 아무 타입이나 받으면 안 되고 일정 조건을 만족하는 타입만 받고 싶을 때 extends로 제약.

문제 상황: any로 받으면 타입 정보가 사라진다.

function first(arr: any[]): any {
  return arr[0]; // 반환이 any — 이후 타입 안전성 상실
}

패턴: 제네릭 T로 입력·출력 타입을 연결한다. 필요하면 extends로 제약.

function first<T>(arr: T[]): T | undefined {
  return arr[0]; // 입력 타입 그대로 반환
}
first([1, 2, 3]);  // number | undefined
first(["a", "b"]); // string | undefined

// 제약: id를 가진 타입만 허용
function getId<T extends { id: number }>(item: T): number {
  return item.id;
}
getId({ id: 1, name: "김" }); // ✅
getId({ name: "김" });        // ❌ id 없음

주의점: 제네릭을 남용해 실제로는 한 타입에만 쓰이는데 <T>를 붙이면 오히려 복잡해진다. "정말 여러 타입에 재사용되는가"를 기준으로 판단.


2. 함수 오버로드 vs 유니온

언제 쓰나: 하나의 함수가 입력에 따라 다른 반환 타입을 가질 때. 입력·출력의 조합이 정해져 있으면 오버로드, 단순히 여러 타입을 받기만 하면 유니온.

패턴 A — 유니온 (입출력 관계가 느슨할 때):

function format(value: string | number): string {
  return String(value);
}

패턴 B — 오버로드 (입력에 따라 반환이 달라질 때):

function parse(value: string): string;
function parse(value: number): number;
function parse(value: string | number): string | number {
  return typeof value === "string" ? value.trim() : value * 2;
}

const a = parse("hi"); // string으로 추론
const b = parse(42);   // number로 추론

주의점: 오버로드는 선언 시그니처(위 두 줄)와 구현 시그니처(세 번째)가 분리된다. 구현 시그니처는 호출자에게 보이지 않는다. 유니온으로 충분하면 오버로드는 과하다 — 반환 타입이 입력에 따라 갈릴 때만 쓴다.


3. unknown vs any — 그리고 API 에러 처리

언제 쓰나: 타입을 아직 모르는 값(API 응답, JSON.parse 결과, 외부 입력, catch의 error)을 다룰 때. 결론부터: any 대신 unknown을 써라.

3-1. any는 검사를 끄고, unknown은 검사를 강제한다

const data: any = JSON.parse(res);
data.user.name.toUpperCase(); // 런타임에 터져도 컴파일러 침묵 ❌

any는 타입 검사를 통째로 꺼버린다. 반면 unknown은 쓰기 전에 좁히기를 강제한다.

const data: unknown = JSON.parse(res);

// 바로 못 씀 — 검증을 강제당함
if (typeof data === "object" && data !== null && "user" in data) {
  // 여기서 타입 가드로 더 좁혀서 사용
}

핵심 차이: any는 "검사 끄기", unknown은 "검사하기 전엔 못 씀". 외부에서 들어오는 모든 값의 기본 타입은 unknown으로 두는 습관이 안전하다.

3-2. API 응답 타입 정의 — 성공과 실패를 함께

실무에서 API 응답은 성공만 있는 게 아니다. 성공/실패를 discriminated union(1편 참고)으로 정의하면, 응답을 다루는 쪽에서 안전하게 분기된다.

// 성공 시 데이터, 실패 시 에러 정보
type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: ApiError };

// 에러 자체의 타입도 명확히 정의
interface ApiError {
  code: string;      // 예: "UNAUTHORIZED", "NOT_FOUND"
  message: string;   // 사용자/개발자용 메시지
  status: number;    // HTTP 상태 코드
}

이제 응답을 쓰는 쪽:

function render(res: ApiResponse<User>) {
  if (res.success) {
    res.data.name;    // ✅ User
  } else {
    res.error.message; // ✅ ApiError
    res.error.status;  // ✅ number
  }
}

3-3. catch의 error는 unknown이다

TypeScript 4.4+에서 catch의 error는 기본이 unknown이다(그 전에는 any였다). error.message를 바로 못 쓴다 — 검증이 필요하다.

try {
  await fetchUser();
} catch (error) {
  // error는 unknown — 바로 .message 접근 불가
  console.log(error.message); // ❌ 컴파일 에러
}

패턴: 에러 형태를 좁히는 타입 가드를 만들어 둔다.

// Error 인스턴스인지 확인
function isError(value: unknown): value is Error {
  return value instanceof Error;
}

// 우리가 정의한 ApiError 형태인지 확인
function isApiError(value: unknown): value is ApiError {
  return (
    typeof value === "object" &&
    value !== null &&
    "code" in value &&
    "status" in value
  );
}

try {
  await fetchUser();
} catch (error) {
  if (isApiError(error)) {
    console.log(error.status, error.message); // ✅ ApiError로 좁혀짐
  } else if (isError(error)) {
    console.log(error.message);               // ✅ 표준 Error
  } else {
    console.log("알 수 없는 에러", error);     // 최후의 폴백
  }
}

3-4. 실전 조합: fetch 래퍼

위 조각들을 합치면, 던지지 않고 항상 ApiResponse를 반환하는 안전한 래퍼가 된다.

async function apiFetch<T>(url: string): Promise<ApiResponse<T>> {
  try {
    const res = await fetch(url);

    if (!res.ok) {
      return {
        success: false,
        error: { code: "HTTP_ERROR", message: res.statusText, status: res.status },
      };
    }

    const data = (await res.json()) as T; // 실무에선 zod 등으로 검증 권장
    return { success: true, data };
  } catch (error) {
    return {
      success: false,
      error: {
        code: "NETWORK_ERROR",
        message: isError(error) ? error.message : "요청 실패",
        status: 0,
      },
    };
  }
}

// 호출부 — 성공/실패가 타입으로 강제 분기됨
const res = await apiFetch<User>("/api/user/1");
if (res.success) {
  console.log(res.data.name);
} else {
  console.log(res.error.code, res.error.message);
}

주의점:

  • as T로 응답을 단언한 부분은 여전히 "우기기"다. 신뢰할 수 없는 외부 데이터라면 zod 같은 스키마 검증 라이브러리로 런타임 검증까지 하는 게 안전하다. unknown의 가치는 좁히기를 강제하는 데 있는데, 결국 as로 우겨버리면 any와 다를 게 없어진다.
  • 에러 타입(ApiError)을 프로젝트 전역에서 하나로 정의해두면, 에러 처리 코드가 일관되고 새 에러 코드를 추가하기도 쉽다.

정리

상황패턴
여러 타입에 재사용 + 조건 제약제네릭 + extends
입력 따라 반환 타입이 다름함수 오버로드
타입 모르는 외부 값unknown (+ 좁히기)
API 성공/실패 응답discriminated union + ApiError 타입
catch의 errorunknown → 타입 가드로 좁히기

실무 기본기의 관통 원칙: 외부에서 들어오는 값은 믿지 말고, 타입 시스템 경계에서 검증하라. unknown으로 받아 좁혀 쓰는 습관 하나가 런타임 에러를 크게 줄인다.

이전 편: 타입 조작 — as const, keyof typeof, 유틸리티 타입

profile
park

0개의 댓글