면접과 실무에서 빈도 높은 세 가지. 특히 마지막 unknown과 API 에러 처리는 실전에서 가장 자주 마주치는 주제다.
이 글에서 다루는 것:
extends)unknown vs any — 그리고 API 응답/에러 타입 정의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>를 붙이면 오히려 복잡해진다. "정말 여러 타입에 재사용되는가"를 기준으로 판단.
언제 쓰나: 하나의 함수가 입력에 따라 다른 반환 타입을 가질 때. 입력·출력의 조합이 정해져 있으면 오버로드, 단순히 여러 타입을 받기만 하면 유니온.
패턴 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로 추론
주의점: 오버로드는 선언 시그니처(위 두 줄)와 구현 시그니처(세 번째)가 분리된다. 구현 시그니처는 호출자에게 보이지 않는다. 유니온으로 충분하면 오버로드는 과하다 — 반환 타입이 입력에 따라 갈릴 때만 쓴다.
unknown vs any — 그리고 API 에러 처리언제 쓰나: 타입을 아직 모르는 값(API 응답, JSON.parse 결과, 외부 입력, catch의 error)을 다룰 때. 결론부터: any 대신 unknown을 써라.
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으로 두는 습관이 안전하다.
실무에서 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
}
}
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); // 최후의 폴백
}
}
위 조각들을 합치면, 던지지 않고 항상 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의 error | unknown → 타입 가드로 좁히기 |
실무 기본기의 관통 원칙: 외부에서 들어오는 값은 믿지 말고, 타입 시스템 경계에서 검증하라. unknown으로 받아 좁혀 쓰는 습관 하나가 런타임 에러를 크게 줄인다.
이전 편: 타입 조작 — as const, keyof typeof, 유틸리티 타입