타입 추론 에러 메시지를 읽는 법
에러가 길어지는 이유
제네릭을 쓰기 시작하면 에러 메시지의 길이가 갑자기 늘어납니다. 초심자뿐 아니라 경력이 꽤 있는 개발자도 이런 메시지 앞에서 스크롤을 내리다 포기하고 아무 데나 as any를 박아넣는 걸 여러 번 봤습니다. 실제 상황을 하나 보겠습니다.
interface ApiResponse<T> {
data: T;
meta: { page: number; total: number };
}
function unwrap<T>(response: ApiResponse<T>): T {
return response.data;
}
const bad = unwrap({ data: { id: 1 }, meta: { page: 1 } });이 코드를 컴파일하면 대략 이런 에러가 납니다.
Argument of type '{ data: { id: number; }; meta: { page: number; }; }'
is not assignable to parameter of type 'ApiResponse<{ id: number; }>'.
Property 'total' is missing in type '{ page: number; }'
but required in type '{ page: number; total: number; }'.위에서부터가 아니라 아래에서부터 읽는다
이 메시지를 위에서부터 읽으면 "타입 A는 타입 B에 할당할 수 없다"는, 그 자체로는 별 정보가 없는 문장으로 시작합니다. 실전에서 쓸모 있는 정보는 대부분 가장 안쪽, 즉 메시지의 맨 아래에 있습니다. 위 에러의 진짜 원인은 마지막 줄입니다 — meta 객체에 total 속성이 빠졌다는 것. data나 ApiResponse<T>, 제네릭 추론 같은 건 이 에러와 무관합니다. 컴파일러가 "왜 할당이 안 되는지"를 설명하려고 타입을 계속 파고들다 보니, 그 파고든 흔적이 전부 메시지에 남아서 길어지는 것뿐입니다.
이 습관 하나만 들여도 제네릭 에러를 다루는 부담이 크게 줄어듭니다: 에러 메시지를 받으면 맨 아래 문단부터 읽고, 필요하면 위로 거슬러 올라간다.
에디터의 타입 표시를 적극적으로 활용한다
에러가 나기 전에, VS Code 같은 에디터에서 변수나 식 위에 마우스를 올려 "지금 컴파일러가 이 타입을 뭐라고 보고 있는지"를 먼저 확인하는 습관이 에러 메시지 자체를 읽는 것보다 더 빠를 때가 많습니다.
const users = await fetchUsers(); // 여기 users 위에 마우스를 올려본다
const first = users[0]; // first의 타입도 확인fetchUsers()의 반환 타입이 여러분이 기대한 것과 다르다면(예를 들어 User[]가 아니라 User[] | undefined), 에러가 나는 지점이 아니라 훨씬 앞선 지점, 즉 fetchUsers의 시그니처 자체에 문제가 있는 경우가 많습니다. 에러가 발생한 줄만 보고 고치려 하면 증상만 없애는 코드(불필요한 타입 단언, ! 남용)를 쓰게 되기 쉽습니다.
제네릭 추론이 예상과 다르게 "넓어졌을" 때
앞서 satisfies를 다루면서 짧게 언급했던 문제를 여기서 좀 더 구체적으로 봅니다. 제네릭 함수에 객체 리터럴을 넘겼는데, 기대한 것보다 훨씬 넓은 타입으로 추론되어 다음 줄에서 엉뚱한 에러가 나는 경우입니다.
function createState<T>(initial: T) {
let value = initial;
return {
get: () => value,
set: (next: T) => { value = next; },
};
}
const state = createState({ status: "idle" });
state.set({ status: "loading" }); // status가 string으로 추론되어 있으면 통과하지만
// "idle" | "loading" 리터럴 유니온을 의도했다면 이건 문제다{ status: "idle" }을 넘기면 T는 { status: string }으로 넓혀 추론됩니다(리터럴이 아니라 일반 string으로). 그래서 state.set({ status: "loading" })도, state.set({ status: "아무말" })도 똑같이 통과해버립니다. "idle" | "loading"처럼 정해진 값만 허용하고 싶었다면 명시적으로 타입을 지정해야 합니다.
const state = createState<{ status: "idle" | "loading" }>({ status: "idle" });
state.set({ status: "loading" }); // OK
state.set({ status: "아무말" }); // 에러이런 상황을 진단하는 순서는 정해져 있습니다. ① 에러가 난 줄이 아니라, 그 값이 처음 만들어진 지점에서 타입이 무엇으로 추론됐는지 에디터로 확인한다. ② 그 타입이 기대와 다르면, 추론에 맡기지 말고 타입 인수를 명시하거나 satisfies로 원하는 타입을 강제한다. ③ 그래도 안 풀리면 에러 메시지의 맨 아래 문단으로 가서 실제로 어떤 속성·타입이 충돌하는지 확인한다.
제네릭 에러를 무서워하지 않게 되는 건 문법을 더 외워서가 아니라, 이 세 단계를 순서대로 밟는 습관이 몸에 배면서입니다.