본문으로 건너뛰기
Advertisement

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" });

정리 표

패턴제네릭 활용 포인트핵심 이점
RepositoryT extends EntityCRUD 코드 한 번만 구현
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장의 제네릭 기초 위에서 타입으로 복잡한 제약과 변환을 표현하는 방법을 배웁니다.

Advertisement