제네릭을 쓰지 말아야 할 때
제네릭은 읽는 비용을 늘린다
이 책 전체에서 제네릭이 어떤 문제를 푸는지 봤습니다. 그런데 이 마지막 챕터는 반대 방향의 이야기입니다 — 제네릭은 공짜가 아닙니다. 함수 시그니처에 타입 매개변수가 늘어날수록, 그 함수를 처음 읽는 사람이 머릿속에 유지해야 할 정보도 늘어납니다. 지금까지 본 예시들은 모두 "타입 사이의 관계를 보존해야 하는" 정당한 이유가 있었습니다. 이 챕터는 그 이유가 없는데도 제네릭을 쓴 사례를 모았습니다. 전부 리뷰에서 실제로 마주쳤던 코드를 단순화한 것입니다.
사례 1: 하나의 타입만 실제로 넘어오는 제네릭
// 프로젝트 전체에서 이 함수는 오직 UserForm에만 쓰인다
function validateForm<T extends Record<string, unknown>>(
values: T,
rules: Record<keyof T, (v: unknown) => boolean>
): boolean {
return Object.keys(rules).every((key) => rules[key as keyof T](values[key]));
}작성자는 "재사용 가능하게" 만들겠다는 생각으로 제네릭을 붙였지만, 실제 호출부를 코드베이스 전체에서 검색해보면 validateForm<UserFormValues> 한 곳뿐이었습니다. 제네릭이 주는 유연성을 실제로 쓰는 곳이 없으니, 이 코드는 다음과 같이 바꿔도 동작과 안전성이 동일합니다.
function validateForm(
values: UserFormValues,
rules: Record<keyof UserFormValues, (v: unknown) => boolean>
): boolean {
return Object.keys(rules).every(
(key) => rules[key as keyof UserFormValues](values[key as keyof UserFormValues])
);
}"나중에 다른 폼에도 쓸 수도 있으니까"는 제네릭을 붙이는 이유로 충분하지 않습니다. 실제로 두 번째, 세 번째 호출부가 생기는 시점에 제네릭으로 일반화해도 리팩터링 비용은 크지 않습니다. 반대로, 미리 일반화해뒀는데 끝내 한 곳에서만 쓰이는 코드는 계속 남아서 다음 사람이 읽을 때마다 "이게 왜 제네릭이지?"라는 질문을 유발합니다.
사례 2: 유니온 타입 하나로 충분한데 제네릭으로 풀어낸 경우
function getStatusColor<T extends "success" | "error" | "warning">(status: T): string {
const colors = { success: "green", error: "red", warning: "yellow" };
return colors[status];
}T는 이 함수 안에서 반환 타입에도, 다른 매개변수에도 전혀 영향을 주지 않습니다. status의 타입만 제한하고 싶다면 제네릭 없이 그냥 유니온 타입을 매개변수에 직접 쓰면 됩니다.
function getStatusColor(status: "success" | "error" | "warning"): string {
const colors = { success: "green", error: "red", warning: "yellow" };
return colors[status];
}multiple-type-parameters 챕터에서 다룬 기준을 다시 적용하면 이 판단이 쉬워집니다 — 타입 매개변수 T가 반환 타입이나 다른 매개변수의 타입을 결정하지 않는다면, 그 자리는 그냥 구체적인 타입으로 써도 됩니다.
사례 3: 제네릭 대신 오버로드가 더 정확한 경우
function parse<T extends "json" | "text">(
raw: string,
format: T
): T extends "json" ? object : string {
return format === "json" ? JSON.parse(raw) : raw;
} // 실제로는 이렇게 캐스팅 없이는 컴파일도 안 된다이 함수는 조건부 타입으로 반환 타입을 분기하려 했지만, 함수 본문 안에서 그 조건부 타입을 그대로 만족시키는 값을 만들어내기가 까다롭습니다(컴파일러가 T의 구체적인 값을 함수 본문 안에서는 모르기 때문에 결국 타입 단언이 필요해집니다). 이런 "입력값에 따라 반환 타입이 확 달라지는" 상황은 제네릭 조건부 타입보다 함수 오버로드 시그니처로 표현하는 게 더 직접적입니다.
function parse(raw: string, format: "json"): object;
function parse(raw: string, format: "text"): string;
function parse(raw: string, format: "json" | "text"): object | string {
return format === "json" ? JSON.parse(raw) : raw;
}오버로드 시그니처 두 줄은 "호출부에서 "json"을 넘기면 object가, "text"를 넘기면 string이 나온다"는 규칙을 그대로 보여줍니다. 구현부는 두 시그니처를 모두 만족하는 넓은 타입(object | string)으로 한 번만 작성하면 됩니다. 제네릭 조건부 타입으로 억지로 표현하려다 타입 단언을 두세 군데 넣게 된다면, 오버로드로 바꿀 신호로 받아들이면 됩니다.
정리하는 대신, 스스로에게 던질 질문 하나
이 책에서 "요약"이라는 절을 따로 두지 않았습니다. 대신 앞으로 제네릭을 쓸지 말지 고민될 때마다 스스로에게 던질 질문 하나를 남깁니다.
"이 타입 매개변수를 지우면, 이 코드가 실제로 덜 정확해지는가?"
지워도 정확성이 그대로라면 — 즉 구체적인 타입이나 유니온으로 똑같이 표현할 수 있다면 — 제네릭은 불필요한 추상화입니다. 지우는 순간 관계가 깨지고 타입 안전성이 사라진다면, 그게 바로 이 책 1장에서부터 이야기한 "제네릭이 필요한 순간"입니다. 문법을 안다고 좋은 타입을 쓰는 게 아니라, 이 질문에 정직하게 답할 수 있어야 좋은 타입을 씁니다.