Java의 List<T>, Optional<T>를 써봤다면 TypeScript 제네릭은 이미 절반은 아는 것이다. 개념은 동일하다 — 타입을 파라미터로 받아서 재사용 가능한 코드를 만든다. any를 쓰지 않고도 다양한 타입을 처리할 수 있다.
제네릭이 필요한 이유
any를 쓰면 일단 동작은 하지만 타입 정보가 사라진다.
// any — 타입 정보 소실
function identity(value: any): any {
return value
}
const result = identity('hello') // 타입: any
result.toUpperCase() // OK인지 런타임 전엔 모름
// 제네릭 — 타입 유지
function identity<T>(value: T): T {
return value
}
const result = identity('hello') // 타입: string (자동 추론)
const result = identity<number>(42) // 타입: number (명시)
<T>가 타입 파라미터다. Java의 <T>와 같다.
함수 제네릭
// 배열 첫 번째 요소 반환
function first<T>(arr: T[]): T | undefined {
return arr[0]
}
first(['a', 'b', 'c']) // string | undefined
first([1, 2, 3]) // number | undefined
// 두 타입 파라미터
function swap<T, U>(a: T, b: U): [U, T] {
return [b, a]
}
const [b, a] = swap('hello', 42) // [number, string]
인터페이스 제네릭 — API 응답 래퍼
실무에서 가장 자주 쓰이는 패턴이다. 모든 API 응답을 하나의 제네릭 인터페이스로 감싼다.
interface ApiResponse<T> {
data: T
message: string
success: boolean
timestamp: string
}
interface User { id: number; name: string }
interface Order { id: number; total: number }
const userResponse: ApiResponse<User> = {
data: { id: 1, name: '홍길동' },
message: '조회 성공',
success: true,
timestamp: '2026-06-20',
}
const ordersResponse: ApiResponse<Order[]> = {
data: [{ id: 1, total: 15000 }],
message: '주문 목록 조회 성공',
success: true,
timestamp: '2026-06-20',
}
페이지네이션, Repository 패턴도 같은 방식으로 표현한다.
interface Paginated<T> {
items: T[]
total: number
page: number
size: number
hasNext: boolean
}
interface Repository<T, ID> {
findById(id: ID): Promise<T | null>
findAll(): Promise<T[]>
save(entity: Omit<T, 'id'>): Promise<T>
update(id: ID, entity: Partial<T>): Promise<T>
delete(id: ID): Promise<void>
}
제약 조건 — extends로 타입 파라미터를 제한
타입 파라미터가 특정 구조를 가져야 할 때 extends로 제약을 건다.
// T는 반드시 id 프로퍼티를 가져야 함
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id)
}
findById([{ id: 1, name: '홍' }], 1) // OK
findById(['a', 'b'], 1) // ❌ string에는 id가 없음
keyof 제약 — 실제 존재하는 키만 허용
keyof T는 객체 T의 키 타입을 추출한다. Java에는 없는 개념이다.
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { name: '홍길동', age: 30 }
getProperty(user, 'name') // string
getProperty(user, 'age') // number
getProperty(user, 'email') // ❌ 컴파일 오류 — email 키 없음
K extends keyof T는 “K는 T의 실제 키 중 하나"를 의미한다. 존재하지 않는 키로 접근하면 컴파일 오류가 난다.
기본값 타입
interface Container<T = string> { // T가 없으면 string
value: T
}
const c1: Container = { value: '기본' } // Container<string>
const c2: Container<number> = { value: 42 } // Container<number>
실무 패턴 — API 호출 함수
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(url)
const json = await res.json()
return json as ApiResponse<T>
}
// 사용 — T만 바꾸면 모든 엔드포인트에 재사용
const userRes = await fetchApi<User>('/api/users/1') // ApiResponse<User>
const ordersRes = await fetchApi<Order[]>('/api/orders') // ApiResponse<Order[]>
타입 파라미터 하나로 모든 API 응답을 타입 안전하게 처리한다.
Java와의 차이점
Java 제네릭과 개념은 같지만 두 가지 차이가 있다.
1. 타입 소거(Type Erasure): Java는 런타임에 제네릭 타입 정보가 지워진다(List<String>이 런타임엔 그냥 List). TypeScript도 마찬가지다 — 컴파일 후 .js에는 타입 정보가 없다.
2. 구조적 타이핑: Java는 T extends Comparable<T> 처럼 명시적 인터페이스가 필요하지만, TypeScript는 구조만 맞으면 된다.
// Java라면 T extends HasId<T> 같은 선언 필요
// TypeScript는 그냥 구조로 제약
function findById<T extends { id: number }>(items: T[], id: number) {
return items.find(item => item.id === id)
}
마치며
TypeScript 제네릭은 Java에서 이미 익숙한 개념이다. ApiResponse<T>, Paginated<T> 같은 공통 래퍼를 한 번 만들어두면 프로젝트 전체에서 타입 일관성이 생긴다.
다음 글에서는 TS가 기본 제공하는 유틸리티 타입들 — Partial, Pick, Omit, Record 등을 실무 API 패턴과 함께 살펴본다.