5.5 제네릭 실전 패턴
실전 패턴이 필요한 이유
제네릭 기초를 배웠다면 이제 실무에서 자주 쓰이는 설계 패턴에 제네릭을 적용할 차례입니다. 좋은 제네릭 패턴은 타입 안전성을 유지하면서 코드 재사용성을 극대화합니다. 이 절에서는 각 패턴이 왜 필요한지, 어떻게 제네릭으로 구현하는지를 실제 코드로 익힙니다.
제네릭 Repository 패턴
Repository 패턴은 데이터 접근 로직을 캡슐화합니다. 제네릭을 적용하면 엔티티마다 CRUD 코드를 반복하지 않아도 됩니다.
// 기반 엔티티 인터페이스
interface Entity {
id: number;
}
// 제네릭 Repository 인터페이스
interface Repository<T extends Entity> {
findById(id: number): Promise<T | null>;
findAll(): Promise<T[]>;
findBy(predicate: Partial<T>): Promise<T[]>;
create(data: Omit<T, "id">): Promise<T>;
update(id: number, data: Partial<Omit<T, "id">>): Promise<T | null>;
delete(id: number): Promise<boolean>;
}
// 인메모리 구현 (테스트용)
class InMemoryRepository<T extends Entity> implements Repository<T> {
private store: Map<number, T> = new Map();
private nextId = 1;
async findById(id: number): Promise<T | null> {
return this.store.get(id) ?? null;
}
async findAll(): Promise<T[]> {
return Array.from(this.store.values());
}
async findBy(predicate: Partial<T>): Promise<T[]> {
const entries = Array.from(this.store.values());
return entries.filter((item) => {
return (Object.keys(predicate) as Array<keyof T>).every(
(key) => item[key] === predicate[key]
);
});
}
async create(data: Omit<T, "id">): Promise<T> {
const id = this.nextId++;
const entity = { ...data, id } as T;
this.store.set(id, entity);
return entity;
}
async update(id: number, data: Partial<Omit<T, "id">>): Promise<T | null> {
const existing = this.store.get(id);
if (!existing) return null;
const updated = { ...existing, ...data };
this.store.set(id, updated);
return updated;
}
async delete(id: number): Promise<boolean> {
return this.store.delete(id);
}
}
// 엔티티 정의
interface User extends Entity {
name: string;
email: string;
role: "admin" | "user";
}
interface Product extends Entity {
name: string;
price: number;
stock: number;
category: string;
}
// 특화 Repository: 공통 기능 + 도메인 특화 메서드
class UserRepository extends InMemoryRepository<User> {
async findByEmail(email: string): Promise<User | null> {
const results = await this.findBy({ email } as Partial<User>);
return results[0] ?? null;
}
async findAdmins(): Promise<User[]> {
return this.findBy({ role: "admin" } as Partial<User>);
}
}
class ProductRepository extends InMemoryRepository<Product> {
async findByCategory(category: string): Promise<Product[]> {
return this.findBy({ category } as Partial<Product>);
}
async findInStock(): Promise<Product[]> {
const all = await this.findAll();
return all.filter((p) => p.stock > 0);
}
async decreaseStock(id: number, quantity: number): Promise<Product | null> {
const product = await this.findById(id);
if (!product || product.stock < quantity) return null;
return this.update(id, { stock: product.stock - quantity });
}
}
// 사용
const userRepo = new UserRepository();
const productRepo = new ProductRepository();
(async () => {
const alice = await userRepo.create({ name: "Alice", email: "alice@example.com", role: "admin" });
const bob = await userRepo.create({ name: "Bob", email: "bob@example.com", role: "user" });
const found = await userRepo.findByEmail("alice@example.com");
console.log(found?.name); // "Alice"
const admins = await userRepo.findAdmins();
console.log(admins.length); // 1
})();
Builder 패턴
Builder 패턴은 복잡한 객체를 단계별로 구성합니다. 제네릭과 this 타입을 활용하면 메서드 체이닝의 타입 안전성을 유지하면서 상속도 지원합니다.
// 쿼리 빌더 예시
interface QueryConfig<T> {
table: string;
conditions: Array<{ field: keyof T; operator: string; value: unknown }>;
orderBy: Array<{ field: keyof T; direction: "asc" | "desc" }>;
limitValue?: number;
offsetValue?: number;
selectedFields?: Array<keyof T>;
}
class QueryBuilder<T extends object> {
protected config: QueryConfig<T>;
constructor(table: string) {
this.config = {
table,
conditions: [],
orderBy: [],
};
}
// this 타입 반환 — 서브클래스에서 체이닝 시 서브클래스 타입 유지
where<K extends keyof T>(
field: K,
operator: "=" | "!=" | ">" | "<" | ">=" | "<=",
value: T[K]
): this {
this.config.conditions.push({ field, operator, value });
return this;
}
orderByField(field: keyof T, direction: "asc" | "desc" = "asc"): this {
this.config.orderBy.push({ field, direction });
return this;
}
limit(n: number): this {
this.config.limitValue = n;
return this;
}
offset(n: number): this {
this.config.offsetValue = n;
return this;
}
select(...fields: Array<keyof T>): this {
this.config.selectedFields = fields;
return this;
}
build(): string {
const fields = this.config.selectedFields
? this.config.selectedFields.join(", ")
: "*";
let query = `SELECT ${fields} FROM ${this.config.table}`;
if (this.config.conditions.length > 0) {
const where = this.config.conditions
.map((c) => `${String(c.field)} ${c.operator} '${c.value}'`)
.join(" AND ");
query += ` WHERE ${where}`;
}
if (this.config.orderBy.length > 0) {
const order = this.config.orderBy
.map((o) => `${String(o.field)} ${o.direction.toUpperCase()}`)
.join(", ");
query += ` ORDER BY ${order}`;
}
if (this.config.limitValue !== undefined) {
query += ` LIMIT ${this.config.limitValue}`;
}
if (this.config.offsetValue !== undefined) {
query += ` OFFSET ${this.config.offsetValue}`;
}
return query;
}
}
// 서브클래스: this 타입 덕분에 체이닝 가능
class UserQueryBuilder extends QueryBuilder<User> {
activeOnly(): this {
return this.where("role", "=", "user" as User["role"]);
}
adminOnly(): this {
return this.where("role", "=", "admin" as User["role"]);
}
}
const query = new UserQueryBuilder("users")
.adminOnly()
.orderByField("name", "asc")
.limit(10)
.select("id", "name", "email")
.build();
// SELECT id, name, email FROM users WHERE role = 'admin' ORDER BY name ASC LIMIT 10
console.log(query);
불변 Builder 패턴
// 각 단계마다 새 인스턴스를 반환하는 불변 Builder
class ImmutableBuilder<T extends Record<string, unknown>> {
private constructor(private readonly data: Partial<T> = {}) {}
static create<T extends Record<string, unknown>>(): ImmutableBuilder<T> {
return new ImmutableBuilder<T>();
}
set<K extends keyof T>(key: K, value: T[K]): ImmutableBuilder<T> {
return new ImmutableBuilder<T>({ ...this.data, [key]: value });
}
build(): T {
return this.data as T;
}
toPartial(): Partial<T> {
return { ...this.data };
}
}
interface EmailConfig {
from: string;
to: string[];
subject: string;
body: string;
cc?: string[];
bcc?: string[];
replyTo?: string;
}
const email = ImmutableBuilder.create<EmailConfig>()
.set("from", "sender@example.com")
.set("to", ["recipient@example.com"])
.set("subject", "테스트 메일")
.set("body", "안녕하세요!")
.build();
console.log(email.subject); // "테스트 메일"
API 응답 래퍼
Result<T, E>
성공과 실패를 명확하게 구분하는 타입 안전한 Result 타입입니다.
// Result 타입 — Rust의 Result<T, E>와 유사
type Result<T, E = Error> =
| { success: true; data: T; error: null }
| { success: false; data: null; error: E };
// 헬퍼 함수
function ok<T>(data: T): Result<T, never> {
return { success: true, data, error: null };
}
function err<E>(error: E): Result<never, E> {
return { success: false, data: null, error };
}
// 사용
async function fetchUser(id: number): Promise<Result<User, string>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return err(`HTTP ${response.status}: ${response.statusText}`);
}
const user = await response.json() as User;
return ok(user);
} catch (e) {
return err(`네트워크 오류: ${String(e)}`);
}
}
// 타입 안전한 처리
const result = await fetchUser(1);
if (result.success) {
console.log(result.data.name); // User 타입 확정
} else {
console.error(result.error); // string 타입 확정
}
Paginated<T>
// 페이지네이션 래퍼
interface Paginated<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
function createPaginated<T>(
data: T[],
page: number,
pageSize: number,
totalItems: number
): Paginated<T> {
const totalPages = Math.ceil(totalItems / pageSize);
return {
data,
pagination: {
page,
pageSize,
totalItems,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
};
}
async function fetchUsers(page = 1, pageSize = 20): Promise<Paginated<User>> {
const response = await fetch(`/api/users?page=${page}&size=${pageSize}`);
const json = await response.json();
return createPaginated<User>(json.users, page, pageSize, json.total);
}
ApiResponse<T>
// 통합 API 응답 타입
interface ApiResponse<T> {
success: boolean;
data: T | null;
error: { code: string; message: string } | null;
meta: {
requestId: string;
timestamp: string;
version: string;
};
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get<T>(path: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${path}`);
const json = await response.json();
return json as ApiResponse<T>;
}
async post<TRequest, TResponse>(
path: string,
body: TRequest
): Promise<ApiResponse<TResponse>> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json() as Promise<ApiResponse<TResponse>>;
}
}
const apiClient = new ApiClient("https://api.example.com");
const userResponse = await apiClient.get<User>("/users/1");
if (userResponse.success && userResponse.data) {
console.log(userResponse.data.name); // 타입 안전
}
제네릭 훅 패턴 (React)
import { useState, useEffect, useCallback } from "react";
// useFetch<T>: 제네릭 데이터 페칭 훅
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
function useFetch<T>(
url: string,
options?: RequestInit
): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0);
const refetch = useCallback(() => setVersion((v) => v + 1), []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url, options)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((result) => {
if (!cancelled) {
setData(result);
setLoading(false);
}
})
.catch((e: Error) => {
if (!cancelled) {
setError(e.message);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url, version]);
return { data, loading, error, refetch };
}
// useLocalStorage<T>: 제네릭 로컬 스토리지 훅
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const newValue = value instanceof Function ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(newValue));
return newValue;
});
},
[key]
);
const removeValue = useCallback(() => {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
// 사용 예시 (React 컴포넌트 내부)
function UserProfile() {
// T = User로 자동 추론
const { data: user, loading, error } = useFetch<User>("/api/me");
const [theme, setTheme] = useLocalStorage<"light" | "dark">(
"theme",
"light"
);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>오류: {error}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<button onClick={() => setTheme((t) => t === "light" ? "dark" : "light")}>
현재 테마: {theme}
</button>
</div>
);
}
제네릭 이벤트 에미터
// 이벤트 맵 타입: 이벤트명 → 페이로드 타입
type EventMap = Record<string, unknown>;
class EventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): () => void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
// 구독 해제 함수 반환
return () => this.off(event, listener);
}
once<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): () => void {
const wrapper = (payload: Events[K]) => {
listener(payload);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
off<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
const list = this.listeners[event];
if (list) {
this.listeners[event] = list.filter((l) => l !== listener) as typeof list;
}
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
const list = this.listeners[event];
if (list) {
list.forEach((listener) => listener(payload));
}
}
removeAllListeners<K extends keyof Events>(event?: K): void {
if (event) {
delete this.listeners[event];
} else {
this.listeners = {};
}
}
}
// 사용: 이벤트 맵 정의
interface AppEvents {
userLogin: { userId: number; username: string; timestamp: Date };
userLogout: { userId: number };
postCreated: { postId: number; title: string; authorId: number };
error: { code: string; message: string; details?: unknown };
}
const emitter = new EventEmitter<AppEvents>();
// 타입 안전한 이벤트 구독
const unsubscribe = emitter.on("userLogin", ({ userId, username }) => {
console.log(`${username}(${userId})가 로그인했습니다.`);
});
emitter.on("error", ({ code, message }) => {
console.error(`[${code}] ${message}`);
});
// 타입 안전한 이벤트 발행
emitter.emit("userLogin", {
userId: 1,
username: "Alice",
timestamp: new Date(),
});
emitter.emit("error", { code: "AUTH_FAIL", message: "인증 실패" });
// 잘못된 이벤트는 컴파일 오류
// emitter.emit("nonExistent", {}); // Error
// emitter.emit("userLogin", { wrong: "data" }); // Error
// 구독 해제
unsubscribe();
실전 예제: 타입 안전한 상태 머신
// 상태 머신 정의
type StateMachineConfig<
State extends string,
Event extends string
> = {
initial: State;
states: {
[S in State]: {
on?: Partial<Record<Event, State>>;
entry?: () => void;
exit?: () => void;
};
};
};
class StateMachine<State extends string, Event extends string> {
private currentState: State;
private config: StateMachineConfig<State, Event>;
private listeners: Array<(state: State, event: Event) => void> = [];
constructor(config: StateMachineConfig<State, Event>) {
this.config = config;
this.currentState = config.initial;
config.states[config.initial].entry?.();
}
getState(): State {
return this.currentState;
}
send(event: Event): boolean {
const stateConfig = this.config.states[this.currentState];
const nextState = stateConfig.on?.[event];
if (!nextState) return false;
stateConfig.exit?.();
this.currentState = nextState;
this.config.states[nextState].entry?.();
this.listeners.forEach((l) => l(nextState, event));
return true;
}
can(event: Event): boolean {
return !!this.config.states[this.currentState].on?.[event];
}
onTransition(listener: (state: State, event: Event) => void): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
}
// 신호등 상태 머신
type TrafficState = "red" | "yellow" | "green";
type TrafficEvent = "timer" | "emergency";
const trafficLight = new StateMachine<TrafficState, TrafficEvent>({
initial: "red",
states: {
red: {
on: { timer: "green" },
entry: () => console.log("빨간불: 정지"),
},
yellow: {
on: { timer: "red" },
entry: () => console.log("노란불: 준비"),
},
green: {
on: { timer: "yellow", emergency: "red" },
entry: () => console.log("초록불: 출발"),
},
},
});
trafficLight.onTransition((state) => console.log(`상태 변경 → ${state}`));
trafficLight.send("timer"); // 빨간불 → 초록불
trafficLight.send("emergency"); // 초록불 → 빨간불
console.log(trafficLight.can("timer")); // true
제네릭 폼 처리
// 폼 필드 정의
type FieldConfig<T> = {
[K in keyof T]: {
label: string;
type: "text" | "email" | "password" | "number" | "select";
required?: boolean;
validate?: (value: T[K]) => string | null;
options?: T[K] extends string ? string[] : never;
};
};
interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
isValid: boolean;
isDirty: boolean;
}
class GenericForm<T extends Record<string, unknown>> {
private state: FormState<T>;
private config: FieldConfig<T>;
private changeListeners: Array<(state: FormState<T>) => void> = [];
constructor(initialValues: T, config: FieldConfig<T>) {
this.config = config;
this.state = {
values: { ...initialValues },
errors: {},
touched: {},
isValid: true,
isDirty: false,
};
}
setValue<K extends keyof T>(field: K, value: T[K]): void {
this.state = {
...this.state,
values: { ...this.state.values, [field]: value },
touched: { ...this.state.touched, [field]: true },
isDirty: true,
};
this.validate();
this.notifyListeners();
}
private validate(): void {
const errors: Partial<Record<keyof T, string>> = {};
let isValid = true;
(Object.keys(this.config) as Array<keyof T>).forEach((key) => {
const fieldConfig = this.config[key];
const value = this.state.values[key];
if (fieldConfig.required && !value) {
errors[key] = `${fieldConfig.label}은(는) 필수입니다.`;
isValid = false;
} else if (fieldConfig.validate) {
const error = fieldConfig.validate(value);
if (error) {
errors[key] = error;
isValid = false;
}
}
});
this.state = { ...this.state, errors, isValid };
}
getState(): Readonly<FormState<T>> {
return this.state;
}
reset(initialValues: T): void {
this.state = {
values: { ...initialValues },
errors: {},
touched: {},
isValid: true,
isDirty: false,
};
this.notifyListeners();
}
onChange(listener: (state: FormState<T>) => void): () => void {
this.changeListeners.push(listener);
return () => {
this.changeListeners = this.changeListeners.filter((l) => l !== listener);
};
}
private notifyListeners(): void {
this.changeListeners.forEach((l) => l(this.state));
}
}
// 사용
interface LoginFormValues {
email: string;
password: string;
}
const loginForm = new GenericForm<LoginFormValues>(
{ email: "", password: "" },
{
email: {
label: "이메일",
type: "email",
required: true,
validate: (value) => {
if (!value.includes("@")) return "유효한 이메일을 입력하세요.";
return null;
},
},
password: {
label: "비밀번호",
type: "password",
required: true,
validate: (value) => {
if (value.length < 8) return "비밀번호는 8자 이상이어야 합니다.";
return null;
},
},
}
);
loginForm.onChange(({ isValid, errors }) => {
console.log("유효:", isValid, "오류:", errors);
});
loginForm.setValue("email", "invalid-email");
// 유효: false, 오류: { email: "유효한 이메일을 입력하세요." }
loginForm.setValue("email", "user@example.com");
loginForm.setValue("password", "secret123");
// 유효: true, 오류: {}
고수 팁
제네릭 클래스 vs 제네릭 메서드 선택
// 제네릭 클래스: 인스턴스 전체에서 타입 공유
class TypedStorage<T> {
constructor(private key: string) {}
save(data: T): void {
localStorage.setItem(this.key, JSON.stringify(data));
}
load(): T | null {
const item = localStorage.getItem(this.key);
return item ? (JSON.parse(item) as T) : null;
}
}
// T가 클래스 수명 동안 고정됨
const userStorage = new TypedStorage<User>("current-user");
userStorage.save({ id: 1, name: "Alice", email: "a@b.com", role: "user" });
// 제네릭 메서드: 호출마다 다른 타입
class FlexibleStorage {
save<T>(key: string, data: T): void {
localStorage.setItem(key, JSON.stringify(data));
}
load<T>(key: string): T | null {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : null;
}
}
// 호출마다 다른 타입 가능
const storage = new FlexibleStorage();
storage.save<User>("user", { id: 1, name: "Alice", email: "a@b.com", role: "user" });
storage.save<string[]>("tags", ["ts", "generic"]);
// 선택 기준:
// - 인스턴스 생성 시 타입이 결정되면 → 제네릭 클래스
// - 메서드 호출 시마다 다른 타입이면 → 제네릭 메서드
// - 여러 타입을 하나의 인스턴스로 처리해야 하면 → 제네릭 메서드
타입 파라미터 전파 전략
// 좋지 않은 전파: 불필요하게 모든 곳에 타입 전달
class OverEngineered<T, U, V, W> {
constructor(
private converter: (input: T) => U,
private formatter: (value: U) => V,
private renderer: (formatted: V) => W
) {}
process(input: T): W {
return this.renderer(this.formatter(this.converter(input)));
}
}
// 더 나은 전파: 필요한 곳에만 타입 노출
class Pipeline<TInput, TOutput> {
private steps: Array<(value: unknown) => unknown> = [];
static create<T>(): Pipeline<T, T> {
return new Pipeline<T, T>();
}
pipe<TNext>(fn: (value: TOutput) => TNext): Pipeline<TInput, TNext> {
const next = new Pipeline<TInput, TNext>();
next.steps = [...this.steps, fn as (v: unknown) => unknown];
return next;
}
run(input: TInput): TOutput {
return this.steps.reduce(
(value, step) => step(value),
input as unknown
) as TOutput;
}
}
// 사용: 타입이 파이프라인을 따라 자연스럽게 흐름
const processor = Pipeline.create<string>()
.pipe((s) => s.trim()) // string → string
.pipe((s) => s.split(",")) // string → string[]
.pipe((arr) => arr.map(Number)); // string[] → number[]
const result = processor.run("1, 2, 3, 4, 5");
// number[] = [1, 2, 3, 4, 5]
제네릭 미들웨어 패턴
// Express 스타일 미들웨어 타입
type Middleware<TContext extends object> = (
ctx: TContext,
next: () => Promise<void>
) => Promise<void>;
class MiddlewareChain<TContext extends object> {
private middlewares: Middleware<TContext>[] = [];
use(middleware: Middleware<TContext>): this {
this.middlewares.push(middleware);
return this;
}
async execute(ctx: TContext): Promise<void> {
const dispatch = async (index: number): Promise<void> => {
if (index >= this.middlewares.length) return;
await this.middlewares[index](ctx, () => dispatch(index + 1));
};
await dispatch(0);
}
}
// 사용
interface RequestContext {
path: string;
method: string;
userId?: number;
startTime?: number;
responseTime?: number;
}
const chain = new MiddlewareChain<RequestContext>()
.use(async (ctx, next) => {
ctx.startTime = Date.now();
await next();
ctx.responseTime = Date.now() - ctx.startTime!;
console.log(`${ctx.method} ${ctx.path}: ${ctx.responseTime}ms`);
})
.use(async (ctx, next) => {
console.log(`인증 확인: ${ctx.path}`);
ctx.userId = 1; // 실제로는 토큰 검증
await next();
})
.use(async (ctx) => {
console.log(`핸들러 실행: ${ctx.userId}의 ${ctx.path} 요청`);
});
await chain.execute({ path: "/users", method: "GET" });
정리 표
| 패턴 | 제네릭 활용 포인트 | 핵심 이점 |
|---|---|---|
| Repository | T extends Entity | CRUD 코드 한 번만 구현 |
| Builder (this 타입) | this 반환 타입 | 서브클래스도 체이닝 유지 |
| Builder (불변) | 각 단계 새 인스턴스 | 안전한 체이닝, 부작용 없음 |
| Result<T, E> | 성공/실패 타입 분리 | 오류 처리 강제, null 방지 |
| Paginated<T> | 데이터 타입 래핑 | 페이지네이션 타입 재사용 |
| useFetch<T> | 반환 데이터 타입 | 훅 재사용 + 타입 안전성 |
| EventEmitter<Events> | 이벤트 맵 타입 | 이벤트명·페이로드 모두 안전 |
| StateMachine<S, E> | 상태·이벤트 타입 | 전이 오류 컴파일 타임 검출 |
| GenericForm<T> | 폼 값 타입 | 필드 타입 안전 접근 |
| Pipeline<TIn, TOut> | 단계별 타입 변환 | 체이닝 전후 타입 추적 |
다음 장에서는...
6장에서는 고급 타입 시스템을 다룹니다. 템플릿 리터럴 타입, Mapped Types 심화, 재귀 타입, 타입 레벨 프로그래밍 패턴 등 TypeScript 타입 시스템의 더 깊은 기능들을 탐구합니다. 5장의 제네릭 기초 위에서 타입으로 복잡한 제약과 변환을 표현하는 방법을 배웁니다.