제네릭 기초 — 함수, 인터페이스, 클래스에 타입 변수 적용
제네릭(Generic)은 타입을 매개변수처럼 사용하는 것입니다. 함수를 호출할 때 인수를 넘기듯, 타입도 "넘겨서" 재사용 가능한 코드를 만듭니다.
왜 제네릭이 필요한가
// ❌ any — 타입 정보가 사라짐
function identity(value: any): any {
return value;
}
const result = identity('hello'); // result: any — string 정보를 잃어버림
// ❌ 타입별로 함수를 만드는 건 비효율적
function identityString(value: string): string { return value; }
function identityNumber(value: number): number { return value; }
// ✅ 제네릭 — 타입을 매개변수로
function identityGeneric<T>(value: T): T {
return value;
}
const str = identityGeneric('hello'); // str: 'hello'
const num = identityGeneric(42); // num: 42
제네릭 함수
// T는 관례적으로 사용하는 타입 매개변수 이름
function wrap<T>(value: T): { value: T } {
return { value };
}
// 호출 시 타입을 명시하거나 추론에 맡기기
const a = wrap<string>('hello'); // 명시: { value: string }
const b = wrap(42); // 추론: { value: number }
// 여러 타입 매개변수
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const p = pair('hello', 42); // [string, number]
화살표 함수에서 제네릭
// 일반적인 화살표 함수 제네릭
const identity = <T>(value: T): T => value;
// TSX 파일에서는 <T>가 JSX 태그로 인식될 수 있으므로
// extends를 붙이는 트릭을 사용
const identityTsx = <T extends unknown>(value: T): T => value;
// 또는 trailing comma
const identityTsx2 = <T,>(value: T): T => value;
제네릭 인터페이스
// API 응답 타입을 제네릭으로 정의
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
// 다양한 응답 타입에 재사용
interface User {
id: number;
name: string;
}
interface Product {
id: number;
title: string;
price: number;
}
type UserResponse = ApiResponse<User>;
// { data: User; status: number; message: string }
type ProductResponse = ApiResponse<Product>;
// { data: Product; status: number; message: string }
type ListResponse = ApiResponse<User[]>;
// { data: User[]; status: number; message: string }
기본 타입 매개변수
// 기본값을 지정할 수 있음
interface Container<T = string> {
value: T;
}
const c1: Container = { value: 'hello' }; // T = string (기본값)
const c2: Container<number> = { value: 42 }; // T = number (명시)
제네릭 클래스
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
get size(): number {
return this.items.length;
}
}
// 문자열 스택
const stringStack = new Stack<string>();
stringStack.push('hello');
stringStack.push('world');
const top = stringStack.pop(); // string | undefined
// 숫자 스택
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
제네릭 타입 별칭
// 트리 구조를 제네릭으로
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};
const tree: TreeNode<string> = {
value: '루트',
children: [
{ value: '자식1', children: [] },
{ value: '자식2', children: [
{ value: '손자1', children: [] },
]},
],
};
// Nullable 유틸리티
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
const username: Nullable<string> = null; // OK
제네릭과 배열 메서드
사실 이미 제네릭을 매일 쓰고 있습니다. Array.map, Promise, useState 모두 제네릭입니다.
// Array<T>.map 내부 시그니처
// map<U>(callbackfn: (value: T, index: number) => U): U[]
const numbers = [1, 2, 3]; // number[]
const strings = numbers.map((n) => String(n)); // string[]
// map<string>으로 추론됨
// Promise<T>
async function fetchUser(): Promise<User> {
const response = await fetch('/api/user');
return response.json();
}
제네릭 네이밍 관례
// T — 일반적인 타입
function identity<T>(value: T): T { return value; }
// K, V — 키/값
function getProperty<K extends keyof T, T>(obj: T, key: K): T[K] {
return obj[key];
}
// E — 요소(Element)
interface Collection<E> {
add(element: E): void;
get(index: number): E;
}
// R — 반환 타입(Return)
type Fn<T, R> = (arg: T) => R;
하나의 문자 대신 의미 있는 이름을 쓸 수도 있습니다.
// 긴 이름 — 복잡한 제네릭에서 가독성이 좋음
interface Repository<Entity, Id = number> {
findById(id: Id): Entity | null;
save(entity: Entity): Id;
}
정리
- 제네릭은 "타입의 매개변수"로, 재사용 가능한 타입 안전 코드를 만든다
- 호출 시 타입을 명시하거나 추론에 맡길 수 있다
- 기본 타입 매개변수로 생략 시 기본값을 지정할 수 있다
- Array, Promise, React의 useState 등 이미 제네릭을 매일 사용하고 있다
- 다음 편에서
extends로 제네릭을 제약하는 방법을 다룬다
댓글 로딩 중...