
Intro
타입스크립트를 사용하다 보면 동일한 로직이지만 다른 타입을 처리해야 하는 상황을 자주 마주하게 됩니다.
이럴 때 제네릭(Generics)은 타입을 마치 변수처럼 사용할 수 있게 해주는 강력한 도구입니다.
제네릭이란?
제네릭은 타입을 함수의 파라미터처럼 사용할 수 있게 해주는 문법입니다. 함수에 값을 전달하듯이, 제네릭을 사용하면 타입을 전달할 수 있습니다.
// 제네릭 없이 작성한 함수
function getFirstNumber(arr: number[]): number {
return arr[0];
}
function getFirstString(arr: string[]): string {
return arr[0];
}
// 제네릭을 사용한 함수
function getFirst<T>(arr: T[]): T {
return arr[0];
}
// 타입을 명시적으로 전달
const firstNum = getFirst<number>([1, 2, 3]);
// 타입을 전달하지 않아도, 인자로부터 자동 추론
const firstStr = getFirst(['a', 'b', 'c']);
제네릭쓰면 뭐가 좋아요?
1. 타입 안정성 유지됩니다
호출시점에 결정되는 타입을 추론하여 반환 타입을 좁힐 수 있습니다.
// any 사용 - 타입 안정성 상실
function wrapInArrayAny(value: any): any[] {
return [value];
}
// 🔴 매개변수의 타입이 유실되어 런타임 에러 가능성 존재
const result1 = wrapInArrayAny(42);
result1.toUpperCase();
// 🟢 제네릭 사용 - 타입 안정성 유지
function wrapInArray<T>(value: T): T[] {
return [value];
}
// 🔴 매개변수 타입이 추론되어 컴파일에러 발생
const result2 = wrapInArray(42);
// result2.toUpperCase();
아래 처럼 제네릭은 여러 개의 타입 파라미터를 동시에 사용할 수 있습니다.
핵심은 호출 시점에 전달한 매개변수의 타입이 보존되어 추론된다는 점입니다.
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const numberAndString = pair(1, 'hello'); // [number, string] 타입
const booleanAndObject = pair(true, { name: 'John' }); // [boolean, { name: string }] 타입
2. 매개변수 타입을 보존하면서 제약 조건(Constraints) 추가하기
extends 키워드를 사용해 제네릭 타입에 제약을 걸 수 있습니다. 이를 통해 특정 속성이나 메서드를 가진 타입만 받을 수 있도록 제한할 수 있습니다.
// 🔴 반환 타입이 any로 추론됩니다.
function getProperty(obj: { [key: string]: any }, key: string) {
return obj[key];
}
// 🟢 반환 타입이 정확하게 추론됩니다.
function getProperty<T extends { [key: string]: any }, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = {
id: 1,
name: 'Jerry',
isAdmin: true,
};
const name = getProperty(user, 'name'); // string
const isAdmin = getProperty(user, 'isAdmin'); // boolean
type ApiResult<T> = T extends { error: any }
? { success: false; error: T['error'] }
: { success: true; data: T };
function processResult<T>(result: T): ApiResult<T> {
if ('error' in (result as any)) {
return { success: false, error: (result as any).error } as ApiResult<T>;
}
return { success: true, data: result } as ApiResult<T>;
}
// 사용 예시
const successResult = processResult({ id: 1, name: 'Alice' });
// { success: true; data: { id: number; name: string } }
const errorResult = processResult({ error: 'Not found' });
// { success: false; error: string }
실무 적용 사례
실제 실무 사례를 통해 제네릭 문법을 언제 쓰면 좋을지 알아보겠습니다.
API 응답 처리
실무에서 가장 흔하게 마주치는 상황입니다. API 응답의 구조는 동일하지만 데이터 타입만 다른 경우 data 필드를 제네릭 변수로 설정하면 유용합니다.
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
timestamp: number;
}
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
title: string;
price: number;
}
// API 호출 함수
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url);
return response.json();
}
// 사용 예시
const userResponse = await fetchData<User>('/api/users/1');
console.log(userResponse.data.email); // 타입 안전하게 접근
const productResponse = await fetchData<Product>('/api/products/1');
console.log(productResponse.data.price); // 타입 안전하게 접근
함수 응답 타입 좁히기
제네릭의 진정한 힘은 타입 추론에서 나옵니다. 다음 사례와 같이 매개변수에 따라 타입이 동적으로 결정되는 경우 제네릭을 통해 반환 타입을 추론할 수 있습니다.
// 배열에서 특정 속성으로 그룹화
function groupBy<T, K extends keyof T>(
array: T[],
key: K
): Record<string, T[]> {
return array.reduce((result, item) => {
const groupKey = String(item[key]);
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
return result;
}, {} as Record<string, T[]>);
}
interface Order {
id: number;
status: 'pending' | 'completed' | 'cancelled';
amount: number;
}
const orders: Order[] = [
{ id: 1, status: 'pending', amount: 100 },
{ id: 2, status: 'completed', amount: 200 },
{ id: 3, status: 'pending', amount: 150 },
];
const groupedByStatus = groupBy(orders, 'status');
// 타입이 자동으로 추론되어 안전하게 사용 가능
// {
// pending: [{ id: 1, ... }, { id: 3, ... }],
// completed: [{ id: 2, ... }]
// }
// 객체 속성 선택하기
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach(key => {
result[key] = obj[key];
});
return result;
}
const user = { id: 1, name: 'Alice', email: 'alice@example.com', age: 30 };
const userBasic = pick(user, ['name', 'email']);
// { name: string; email: string } 타입으로 정확히 추론
위 예제에서 groupBy(orders, 'status')를 호출할 때 명시적으로 타입을 지정하지 않아도, 타입스크립트가 orders의 타입과 'status' 키를 분석해 자동으로 올바른 타입을 추론합니다. 이는 코드를 간결하게 유지하면서도 타입 안정성을 보장합니다.
리터럴 타입을 활용한 구조 분석
리터럴 타입을 활용하면 문자열, 숫자 등의 구체적인 값을 타입으로 사용하여 더욱 정밀한 타입 시스템을 구축할 수 있습니다.
라우트 정의와 타입 안전한 네비게이션
// URL 파라미터를 경로 문자열에서 추출하는 유틸리티 타입
type ExtractParams<Path extends string> =
Path extends `${infer _Start}:${infer Param}/${infer Rest}`
? Record<Param | keyof ExtractParams<`/${Rest}`>, string>
: Path extends `${infer _Start}:${infer Param}`
? Record<Param, string>
: never;
// 라우트 정의 (query 파라미터만 명시)
interface RouteQuery {
'/': never;
'/users': { page: number; limit: number };
'/users/:id': never;
'/posts/:postId/comments/:commentId': { sort: 'asc' | 'desc' };
}
// 쿼리 파라미터 여부
type HasQueryParams<P extends keyof RouteQuery> = RouteQuery[P] extends never ? false : true;
// URL 파라미터 여부
type HasUrlParams<P extends keyof RouteQuery> = ExtractParams<P> extends never ? false : true;
// 기본 매개변수
type DefaultArguments<P extends keyof RouteQuery> = [pathname: P];
// 쿼리 파라미터 추가
type AttacheQueryParams<P extends keyof RouteQuery> = [
pathname: P,
params: { query?: Partial<RouteQuery[P]> },
];
// URL 파라미터 추가
type AttacheUrlParams<P extends keyof RouteQuery> = [
pathname: P,
params: { params: ExtractParams<P> },
];
function navigate<P extends keyof RouteQuery>(
...args: HasUrlParams<P> extends false
? HasQueryParams<P> extends false
? DefaultArguments<P>
: AttacheQueryParams<P>
: HasQueryParams<P> extends false
? AttacheUrlParams<P>
: AttacheUrlParams<P> & AttacheQueryParams<P>
): void {
const [path, params] = args;
console.log('Navigating to:', path, params);
}
// 🟢 루트 경로에는 파라미터가 없습니다.
navigate('/');
// 🟢 /users/:id 에는 URL파라미터가 존재합니다.
navigate('/users/:id', {
params: {
id: '123',
},
});
// 🟢 아래 케이스는 URL파라미터와 query-parameter 둘 다 존재합니다.
navigate('/posts/:postId/comments/:commentId', {
params: {
postId: '123',
commentId: '456',
},
query: {
sort: 'asc',
},
});
설명을 위한 적절한 예시를 찾다가 예시가 상당히 복잡해졌습니다. 동작 방식이 잘 이해되지 않는 분들은 제네릭을 이용해 이런 것도 가능하다라는 결론만 기억하셔도 좋습니다.
위 예시의 시나리오를 먼저 살펴보겠습니다.
최초에 라우터 경로와 쿼리 파라미터를 설정한 이후에 navigate함수를 호출할 때에는 url파라미터와 query파라미터의 존재 여부에 따라 자동 완성 및 컴파일에러가 발생합니다. 이런 식으로 제품 전반에서 사용되는 명세를 컴파일 레벨에서 검증함에 따라 일관적인 코드를 작성할 수 있습니다.
타입 세이프 이벤트 시스템
type EventMap = {
click: { x: number; y: number };
focus: { timestamp: number };
input: { value: string };
submit: { formData: Record<string, string> };
};
class EventEmitter<T extends Record<string, any>> {
private listeners: { [K in keyof T]?: Array<(data: T[K]) => void> } = {};
on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(callback);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.listeners[event]?.forEach(callback => callback(data));
}
}
const emitter = new EventEmitter<EventMap>();
// 🟢 타입 안전한 이벤트 리스너 이벤트 콜백의 매개변수가 추론됩니다.
emitter.on('click', (data) => {
console.log(data.x, data.y); // data는 { x: number; y: number } 타입
});
emitter.emit('input', { value: 'hello' }); // 정확한 타입 체크
// emitter.emit('input', { x: 10 }); // 에러: value 속성이 필요함
이벤트 기반 아키텍처에서 이벤트 이름과 데이터 구조를 타입으로 정의하면, 이벤트를 발행하거나 구독할 때 발생할 수 있는 실수를 컴파일 타임에 잡을 수 있습니다.
제네릭 유틸리티 타입
타입스크립트의 내장 유틸리티 타입들은 모두 제네릭을 활용합니다. 직접 만들어보면 제네릭의 응용력을 이해할 수 있습니다.
DeepPartial - 중첩된 객체의 모든 속성을 optional로 변경
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface Config {
server: {
host: string;
port: number;
ssl: {
enabled: boolean;
cert: string;
};
};
database: {
url: string;
pool: number;
};
}
const partialConfig: DeepPartial<Config> = {
server: {
ssl: {
enabled: true
// cert는 선택적
}
// host, port는 선택적
}
// database는 선택적
};
설정 파일이나 폼 데이터를 부분적으로 업데이트할 때 유용합니다. 내장 Partial<T>은 1단계만 optional로 만들지만, DeepPartial<T>은 중첩된 모든 속성을 optional로 만들어줍니다.
DeepReadonly - 중첩된 객체의 모든 속성을 readonly로 변경
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
const config: DeepReadonly<Config> = {
server: { host: 'localhost', port: 3000, ssl: { enabled: false, cert: '' } },
database: { url: 'db://localhost', pool: 10 }
};
// config.server.port = 8080; // 에러: readonly 속성
// config.server.ssl.enabled = true; // 에러: 중첩된 속성도 readonly
불변성이 중요한 상수나 설정 객체를 정의할 때 사용합니다.
PromiseType - Promise에서 resolve된 타입 추출
type PromiseType<T> = T extends Promise<infer U> ? U : never;
async function fetchUser(): Promise<{ id: number; name: string; email: string }> {
const response = await fetch('/api/user');
return response.json();
}
type FetchedUser = PromiseType<ReturnType<typeof fetchUser>>;
// { id: number; name: string; email: string }
비동기 함수의 반환 타입에서 실제 데이터 타입만 추출하고 싶을 때 사용합니다. API 응답 타입을 별도로 정의하지 않아도 함수의 반환 타입에서 자동으로 추출할 수 있습니다.
기타 유틸 타입 예시
// 함수의 반환 타입 추출
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;
function getUserData() {
return { id: 1, name: 'Alice', roles: ['admin', 'user'] };
}
type UserData = ReturnTypeOf<typeof getUserData>;
// { id: number; name: string; roles: string[] }
// Promise에서 타입 추출
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type User = UnwrapPromise<Promise<{ id: number; name: string }>>;
// { id: number; name: string }
// 배열의 요소 타입 추출
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type NumberArray = number[];
type ElementType = ArrayElement<NumberArray>; // number
// 함수 파라미터 타입 추출
type FirstParameter<T> = T extends (first: infer F, ...args: any[]) => any
? F
: never;
function process(data: { id: number; value: string }, options: { strict: boolean }) {
// ...
}
type FirstParam = FirstParameter<typeof process>;
// { id: number; value: string }
제약 조건을 활용한 타입 좁히기
제약 조건을 적절히 사용하면 런타임 에러를 컴파일 타임에 잡을 수 있습니다. 이는 제네릭의 가장 강력한 활용법 중 하나입니다.
// 객체 타입만 허용
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge({ a: 1 }, { b: 2 }); // OK
// const invalid = merge(1, 2); // 에러: number는 object를 extend하지 않음
// 특정 속성을 가진 타입만 허용
function sortByDate<T extends { createdAt: Date }>(items: T[]): T[] {
return items.sort((a, b) =>
a.createdAt.getTime() - b.createdAt.getTime()
);
}
interface Post {
id: number;
title: string;
createdAt: Date;
}
interface Comment {
id: number;
text: string;
createdAt: Date;
}
const posts: Post[] = [
{ id: 1, title: 'First', createdAt: new Date('2024-01-01') },
{ id: 2, title: 'Second', createdAt: new Date('2024-01-02') },
];
const sortedPosts = sortByDate(posts); // OK
interface Product {
id: number;
name: string;
price: number;
}
const products: Product[] = [];
// const sortedProducts = sortByDate(products); // 에러: createdAt 속성이 없음
이 패턴은 정렬, 필터링, 그룹화 같은 범용 유틸리티 함수를 만들 때 매우 유용합니다. 함수가 동작하기 위해 필요한 속성이나 메서드를 타입 수준에서 강제할 수 있어, 잘못된 데이터를 전달하는 실수를 사전에 방지할 수 있습니다.
// keyof를 활용한 안전한 속성 접근
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Alice', email: 'alice@example.com' };
const name = getProperty(user, 'name'); // string 타입
const id = getProperty(user, 'id'); // number 타입
// const invalid = getProperty(user, 'age'); // 에러: 'age'는 user의 키가 아님
마치며
다양한 예시를 통해 제네릭 문법의 활용성에 대해 알아봤습니다.
제네릭의 가장 강력한 장점은 복잡한 타입을 분해하여 구체적인 타입으로 추론 가능하다는 점입니다. 처음에는 복잡해 보일 수 있지만, 실무에서 반복되는 패턴을 발견할 때마다 제네릭을 적용해보면 점차 익숙해질 것입니다. 제네릭을 활용하면 더 세밀한 타입 좁히기가 가능해지고, 런타임 에러를 컴파일 타임에 잡을 수 있어 안정적인 코드를 작성할 수 있습니다.
'typescript' 카테고리의 다른 글
| satistfies 문법 그래서 언제 쓰는 건가요? (1) | 2025.12.29 |
|---|---|
| 브랜드 타입(Brand Type)을 이용한 타입 좁히기 (0) | 2025.12.23 |
| 객체지향 설계: 자율적인 책임의 힘 (0) | 2025.08.27 |
| 타입스크립트의 공변성과 반변성 (2) | 2024.07.13 |
| any 타입이 유용한 경우 (1) | 2024.07.13 |