TypeScript Generics 실제 예제로 배우기
1. 개요
Generics는 재사용 가능한 코드를 작성하면서도 타입 안전성을 유지할 수 있게 해주는 도구입니다. UserRepository, OrderRepository, ProductRepository를 계속 반복해서 작성하는 대신, 하나의 Repository<T>를 공통으로 사용할 수 있습니다. 이 글에서는 실제 예제들을 통해 설명합니다: generic function, generic class, constraints, multiple type parameters, 그리고 utility types와의 결합. 이 글을 읽고 나면, TypeScript 프로젝트에서 service, repository, API response 계층에 generics를 자신감 있게 적용할 수 있을 것입니다.
2. Generics란 무엇이고 왜 필요한가
Generics를 사용하면 type을 “매개변수화”할 수 있습니다. function이 매개변수를 받는 것처럼, 타입 수준에서 말이죠.
// generics를 사용하지 않는 경우: 각 타입마다 다시 작성해야 함
function firstString(arr: string[]): string {
return arr[0];
}
function firstNumber(arr: number[]): number {
return arr[0];
}
// generics 사용: 모든 타입에 대한 하나의 function
function first<T>(arr: T[]): T {
return arr[0];
}
const name = first(["Alice", "Bob"]); // T = string, 자동 추론
const age = first([30, 25]); // T = number
<T>는 type parameter입니다 — 호출할 때 실제로 전달될 타입을 나타내는 변수입니다. TypeScript는 인자를 기반으로 T를 자동으로 추론(infer)하므로, 수동으로 지정할 필요가 없습니다.
주의:
T,U,K,V는 단지 명명 규칙일 뿐이며, 특별한 의미는 없습니다. 큰 codebase에서는TItem,TResponse같이 더 명확한 이름을 붙이는 것이 훨씬 읽기 좋습니다.
3. 실제 Generic function: groupBy 함수
데이터를 처리할 때 자주 만나는 예제입니다: 특정 필드에 따라 객체들을 그룹화하기.
interface Order {
id: number;
status: "pending" | "paid" | "cancelled";
customerId: number;
}
function groupBy<T, K extends string | number>(
items: T[],
keyFn: (item: T) => K
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of items) {
const key = keyFn(item);
(result[key] ??= []).push(item);
}
return result;
}
const orders: Order[] = [
{ id: 1, status: "paid", customerId: 10 },
{ id: 2, status: "pending", customerId: 11 },
{ id: 3, status: "paid", customerId: 10 },
];
const byStatus = groupBy(orders, (o) => o.status);
T는 배열 내 item의 타입입니다 (Order).K extends string | number는 key가string또는number만 될 수 있도록 제한합니다 — object key는 다른 타입이 될 수 없기 때문입니다.byStatus의 결과 타입은Record<"pending" | "paid" | "cancelled", Order[]>이며, TypeScript는 정확히 어떤 key가 나타날 수 있는지 알고 있습니다.
byStatus.paid -> Order[] (2개 요소)
byStatus.pending -> Order[] (1개 요소)
byStatus.cancelled -> Order[] (strict 모드에서는 undefined일 수 있음)
4. extends를 이용한 Generic Constraints
Constraint은 T가 특정 형태를 만족해야 한다고 제한하며, function 내에서 안전하게 property에 접근할 수 있게 해줍니다.
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
interface Customer extends HasId {
name: string;
email: string;
}
const customers: Customer[] = [
{ id: 1, name: "An", email: "an@example.com" },
{ id: 2, name: "Bình", email: "binh@example.com" },
];
const found = findById(customers, 1); // Customer | undefined
만약 extends HasId가 없다면, item.id 줄에서 오류가 발생합니다. TypeScript가 T에 id property가 있는지 알 수 없기 때문입니다.
error TS2339: Property 'id' does not exist on type 'T'.
이것은 generics를 배울 때 가장 흔한 실수입니다: constraint를 잊어버리고 T의 property에 임의로 접근하는 것이죠.
5. Generic Class: Repository<T> 예제
이것은 클래식한 use case입니다 — 여러 entity를 위한 generic repository입니다.
interface Entity {
id: number;
}
class Repository<T extends Entity> {
private items: Map<number, T> = new Map();
save(item: T): T {
this.items.set(item.id, item);
return item;
}
findById(id: number): T | undefined {
return this.items.get(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
deleteById(id: number): boolean {
return this.items.delete(id);
}
}
interface Product extends Entity {
name: string;
price: number;
}
const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "키보드", price: 500_000 });
productRepo.save({ id: 2, name: "마우스", price: 200_000 });
const all = productRepo.findAll(); // Product[]
Repository<T extends Entity>는 관리되는 모든 entity가id를 가지도록 보장하므로,Map<number, T>에서id를 key로 사용하는 것이 안전합니다.new Repository<Product>()을 선언할 때, 모든 method 반환값은 정확한Product타입을 가집니다 — IDE에서name,price자동완성이 됩니다.
주의: 전체 프로젝트에서
Repository를 단 하나의 entity에만 사용한다면, generics는 과도한 설계입니다. 구체적인 class를 먼저 작성하고, 같은 로직을 2개 이상의 곳에서 사용할 때만 generic화하세요.
6. 여러 타입 매개변수: ApiResponse<TData, TError> 예제
하나 이상의 type을 매개변수화해야 할 때, 여러 type parameter을 동시에 사용합니다.
interface ApiResponse<TData, TError = string> {
success: boolean;
data?: TData;
error?: TError;
}
function fetchSuccess<T>(data: T): ApiResponse<T> {
return { success: true, data };
}
function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
return { success: false, error };
}
interface User {
id: number;
username: string;
}
const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });
const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
code: 404,
message: "사용자를 찾을 수 없습니다",
});
interface 선언줄의 TError = string은 기본 generic type입니다 — 두 번째 type을 전달하지 않으면, TypeScript가 자동으로 string을 사용합니다. 이것이 ApiResponse<User>이 type argument 1개만 있어도 유효한 이유입니다.
7. Generics와 Utility Types의 결합
Partial, Pick, Omit 같은 utility types는 사실 TypeScript에 미리 정의된 generic type입니다. 이들을 자신의 generics과 결합하면 update 함수에 매우 효과적입니다.
interface Customer {
id: number;
name: string;
email: string;
vip: boolean;
}
function updateEntity<T extends { id: number }>(
original: T,
patch: Partial<Omit<T, "id">>
): T {
return { ...original, ...patch };
}
const customer: Customer = { id: 1, name: "An", email: "an@a.com", vip: false };
const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "An", email: "an@a.com", vip: true }
Partial<Omit<T, "id">>는 다음을 의미합니다:T의 모든 필드를 가져오되id는 제외하고, 그 모든 필드를 optional로 만들기.- 이 덕분에,
updateEntity는id를 수정하지 못하도록 하며, 호출자는 변경하고 싶은 필드만 전달하면 됩니다 — REST API의 PATCH 패턴과 매우 유사합니다.
만약 patch에 { id: 2 }를 전달하려 하면, TypeScript가 즉시 오류를 보고합니다. id가 Omit에 의해 허용되는 타입에서 제외되었기 때문입니다:
error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.
8. Conditional Type과 infer: 고급 사용법
한 단계 더 나아가기: function의 반환 type을 자동으로 추출하여 반복 작성을 피하기.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
async function fetchCustomer(id: number): Promise<Customer> {
return { id, name: "An", email: "an@a.com", vip: false };
}
type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer, Promise<Customer>가 아님
infer U는 TypeScript에게Promise<...>안의 type을 “추측”하여U에 할당하도록 합니다.fetchCustomer이 무엇을 반환하는지 미리 알 필요가 없습니다 — type이 자동으로 추론되며,fetchCustomer의 signature가 변경되면FetchResult도 자동으로 업데이트됩니다.
주의: Conditional type과
infer은 매우 강력하지만 코드를 읽기 어렵게 만들 수 있습니다. 재사용되는 type utility가 필요할 때만 사용하세요 (UnwrapPromise같은 경우), 한 번만 사용되는 로직에는 과용하지 마세요.
9. Generic Function의 테스트 작성
Generics는 컴파일 타임의 type-checking에 영향을 미치지만, 런타임 동작은 여전히 일반적인 방식으로 테스트해야 합니다. Vitest를 설치합니다:
<!-- package.json (dependencies 섹션, pom.xml이 아님) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";
interface Entity {
id: number;
}
class Repository<T extends Entity> {
private items: Map<number, T> = new Map();
save(item: T): T {
this.items.set(item.id, item);
return item;
}
findById(id: number): T | undefined {
return this.items.get(id);
}
}
interface Product extends Entity {
name: string;
}
describe("Repository<Product>", () => {
it("id로 product를 저장하고 올바르게 찾아가기", () => {
const repo = new Repository<Product>();
repo.save({ id: 1, name: "키보드" });
const found = repo.findById(1);
expect(found).toBeDefined();
expect(found?.name).toBe("키보드");
});
it("id가 존재하지 않을 때 undefined 반환하기", () => {
const repo = new Repository<Product>();
const found = repo.findById(999);
expect(found).toBeUndefined();
});
});
npx vitest run을 실행하면, 예상된 결과:
✓ Repository<Product> > id로 product를 저장하고 올바르게 찾아가기
✓ Repository<Product> > id가 존재하지 않을 때 undefined 반환하기
Test Files 1 passed (1)
Tests 2 passed (2)
이 테스트는 type을 검사하지 않습니다 (TypeScript compiler가 build 시점에 이미 했습니다). 대신 generic class의 runtime 로직을 검사합니다 — T가 무엇이든 상관없이 Map이 올바르게 작동함을 보장합니다.
10. 흔한 오류와 Generics를 사용하지 말아야 할 때
- 과도한 Generic화 (over-generic):
Repository<T>를 만들었지만 전체 프로젝트에 entity가 하나만 있는 경우입니다. 이때는 구체적인CustomerRepositoryclass가 더 읽기 좋고 유지보수하기 쉽습니다. - Constraint 누락:
function getProp<T>(obj: T, key: string)을 작성하고obj[key]에 접근하면any타입 오류 또는 index signature 오류가 발생합니다. 대신<T, K extends keyof T>(obj: T, key: K): T[K]를 사용해야 합니다. - Generic 대신
any사용:function identity(x: any): any는 type safety를 완전히 잃게 합니다 — TypeScript가 어떤 오류도 보고하지 않습니다. 항상<T>(x: T): T를 우선시하세요. - 너무 많은 type parameter: function이 4-5개의 type parameter를 가지면 코드가 매우 읽기 어렵습니다. 여러 type을 하나의 config interface로 묶는 것을 고려하세요.
// 읽기 어려움
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }
// 더 읽기 좋음
interface MergeInput<TBase, TExtra> {
base: TBase;
extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }
주의: type parameter가 전체 function signature에서 정확히 한 번만 사용된다면, generics이 필요하지 않을 가능성이 높습니다 — 구체적인 type이나
unknown으로 충분합니다.
11. 결론
Generics는 type safety를 포기하지 않으면서 재사용 가능한 코드를 작성하게 해줍니다: 하나의 Repository<T>, 하나의 ApiResponse<TData, TError>로 수십 개의 중복된 class/interface를 대체할 수 있습니다. 핵심은 세 가지를 확실히 이해하는 것입니다 — 기본 type parameter, extends를 이용한 constraint, 그리고 내장 utility types (Partial, Omit, Pick)과의 결합. 처음부터 모든 것을 generic화하지 마세요. codebase의 최소 두 곳 이상에서 logic이 반복되는 것을 볼 때만 generic을 추출하세요.