Back

TypeScript 제네릭 완전 정복: 혼란에서 마스터까지 (실전 패턴 포함)

제네릭은 TypeScript 타입 시스템에서 가장 강력한 기능이에요. 그리고 동시에 가장 오해받는 기능이기도 하죠. <T extends Record<string, unknown>, K extends keyof T> 같은 타입 시그니처를 보고 멘탈이 터진 경험, 다들 있잖아요.

근데 사실 제네릭은 복잡하지 않아요. 그냥 타입을 위한 함수예요. 이게 한 번 찰칵 하고 맞으면 나머지는 다 따라와요. 이 글에서 "제네릭 대충은 아는데…" 수준에서 "타입 세이프한 유틸 라이브러리 만들 수 있다" 수준까지 한 번에 올려볼게요.

제네릭이 정확히 뭔지 (60초 멘탈 모델)

제네릭은 타입 변수예요. 나중에 채워질 타입을 비워두는 빈칸이라고 보면 돼요. 함수 파라미터가 값의 빈칸인 것처럼요.

일반 함수:

function identity(value: string): string { return value; }

이건 문자열만 돼요. 아무 타입이나 되게 하려면?

제네릭 없이 — 타입 정보가 날아감:

function identity(value: any): any { return value; } const result = identity("hello"); // result는 'any' — 쓸모없음

제네릭으로 — 타입이 쭉 흘러감:

function identity<T>(value: T): T { return value; } const result = identity("hello"); // result는 'string' ✅ const num = identity(42); // num은 'number' ✅

T가 제네릭 타입 파라미터예요. identity("hello")를 호출하면 TypeScript가 T = string이라고 추론하고 그 정보를 리턴 타입까지 끌고 가요. 함수를 한 번만 작성하고, 어떤 타입이든 정확하게 작동하면서 타입 안전성도 유지되는 거죠.

이게 전부예요. 진짜로. 앞으로 나올 내용은 죄다 이 한 가지 위에 올라가는 거예요.

기본을 넘어서: 제네릭 제약 조건

제약 없는 제네릭은 아무거나 다 받아요. 항상 원하는 건 아니죠.

문제: 너무 허용적임

function getLength<T>(value: T): number { return value.length; // ❌ 에러: 'T' 타입에 'length' 속성이 없음 }

TypeScript는 Tlength 속성이 있는지 몰라요. 숫자일 수도 있고, 불리언일 수도 있으니까요.

해결: extends 키워드

function getLength<T extends { length: number }>(value: T): number { return value.length; // ✅ 동작 — T에 'length'가 있다고 보장 } getLength("hello"); // ✅ string에 length 있음 getLength([1, 2, 3]); // ✅ array에 length 있음 getLength(42); // ❌ 에러: number에는 length 없음

extends 키워드는 제약 조건을 달아주는 거예요. "T한테 최소한 이 속성은 있어야 해"라고 TS한테 알려주는 거죠. 최소 스펙이라고 생각하면 편해요.

실전 패턴: API 응답 래퍼

interface ApiResponse<T> { data: T; status: number; timestamp: string; } interface User { id: string; name: string; email: string; } interface Product { id: string; title: string; price: number; } type UserResponse = ApiResponse<User>; // { data: User; status: number; timestamp: string; } type ProductResponse = ApiResponse<Product>; // { data: Product; status: number; timestamp: string; }

인터페이스 하나로 무한 재사용. data 필드가 각 유스케이스에 맞게 타입 세이프해요.

다중 타입 파라미터

제네릭은 여러 파라미터를 가질 수 있어요. 함수에 여러 인수가 있는 것처럼:

function pair<A, B>(first: A, second: B): [A, B] { return [first, second]; } const result = pair("hello", 42); // [string, number]

실전 패턴: 타입 세이프 이벤트 이미터

type EventMap = { userLogin: { userId: string; timestamp: Date }; pageView: { url: string; referrer: string }; purchase: { productId: string; amount: number }; }; class TypedEventEmitter<Events extends Record<string, any>> { private handlers: Partial<{ [K in keyof Events]: Array<(payload: Events[K]) => void>; }> = {}; on<K extends keyof Events>( event: K, handler: (payload: Events[K]) => void ): void { if (!this.handlers[event]) { this.handlers[event] = []; } this.handlers[event]!.push(handler); } emit<K extends keyof Events>(event: K, payload: Events[K]): void { this.handlers[event]?.forEach((handler) => handler(payload)); } } const emitter = new TypedEventEmitter<EventMap>(); emitter.on("userLogin", (payload) => { console.log(payload.userId); // ✅ 자동완성 작동 console.log(payload.timestamp); // ✅ 타입 세이프 }); emitter.on("purchase", (payload) => { console.log(payload.amount); // ✅ number }); emitter.emit("pageView", { url: "/home", referrer: "google.com", }); // ✅ 페이로드 형태가 검증됨

이 패턴 쓰면 런타임 버그가 통째로 사라져요. 이벤트 이름이랑 페이로드 구조가 타입으로 묶여 있으니까요. 이벤트 이름을 고치거나 페이로드 구조를 바꾸면 TS가 빌드할 때 깨진 핸들러를 전부 찾아줘요.

제네릭 유틸리티 타입: TypeScript 내장 파워 툴

TypeScript에는 모든 개발자가 알아야 할 제네릭 유틸리티 타입이 내장돼 있어요. 지금 배우고 있는 제네릭 패턴으로 만들어진 거예요.

Partial<T> — 모든 속성을 선택적으로

interface User { name: string; email: string; age: number; } function updateUser(id: string, updates: Partial<User>): void { // updates는 User 속성의 어떤 조합이든 가능 } updateUser("123", { name: "Alice" }); // ✅ updateUser("123", { email: "[email protected]" }); // ✅ updateUser("123", { invalid: true }); // ❌ 에러

Pick<T, K> — 특정 속성만 고르기

type UserPreview = Pick<User, "name" | "email">; // { name: string; email: string; }

Omit<T, K> — 특정 속성 제거하기

type CreateUserDto = Omit<User, "id" | "createdAt">; // User에서 id와 createdAt만 빼고 전부

Record<K, V> — 객체 타입 만들기

type StatusMap = Record<"active" | "inactive" | "banned", User[]>; // { active: User[]; inactive: User[]; banned: User[]; }

ReturnType<T> — 함수 리턴 타입 추출하기

function createUser() { return { id: "1", name: "Alice", role: "admin" as const }; } type NewUser = ReturnType<typeof createUser>; // { id: string; name: string; role: "admin" }

Partial의 내부 구현

실제 구현 코드:

type Partial<T> = { [P in keyof T]?: T[P]; };

이건 맵드 타입이에요. T의 모든 키 P를 순회하면서, 각각을 선택적(?)으로 만들고, 원래 값 타입(T[P])은 그대로 유지해요. 이걸 이해하면 자기만의 유틸리티 타입을 만들 수 있게 돼요.

조건부 타입: 타입 레벨의 로직

조건부 타입은 타입 시스템에서 if/else 로직을 쓸 수 있게 해줘요:

type IsString<T> = T extends string ? "yes" : "no"; type A = IsString<string>; // "yes" type B = IsString<number>; // "no"

좀 뜬구름 잡는 것 같죠? 근데 실무에서 진짜 쓸모 있어요.

실전 패턴: API 응답 언래퍼

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T; type A = UnwrapPromise<Promise<string>>; // string type B = UnwrapPromise<Promise<number>>; // number type C = UnwrapPromise<string>; // string (Promise가 아니면 그대로)

infer 키워드는 패턴 안에서 타입을 캡처해요. 여기서는 Promise<> 안의 내부 타입을 뽑아내는 거죠.

실전 패턴: 깊은 프로퍼티 접근

type NestedValue<T, Path extends string> = Path extends `${infer Key}.${infer Rest}` ? Key extends keyof T ? NestedValue<T[Key], Rest> : never : Path extends keyof T ? T[Path] : never; interface Config { database: { host: string; port: number; credentials: { username: string; password: string; }; }; cache: { ttl: number; }; } type DbHost = NestedValue<Config, "database.host">; // string type DbUser = NestedValue<Config, "database.credentials.username">; // string type CacheTtl = NestedValue<Config, "cache.ttl">; // number

Lodash _.get()이나 tRPC 경로 기반 API가 내부적으로 이런 식으로 돌아가는 거예요. 타입 시스템이 객체 구조를 재귀적으로 따라가면서 리턴 타입을 정확하게 뽑아내죠.

맵드 타입: 타입을 프로그래밍적으로 변환

맵드 타입은 기존 타입을 변환해서 새 타입을 만들어요:

// 모든 속성을 readonly로 type Readonly<T> = { readonly [P in keyof T]: T[P]; }; // 모든 속성을 nullable로 type Nullable<T> = { [P in keyof T]: T[P] | null; }; // 모든 속성을 필수로 (optional 제거) type Required<T> = { [P in keyof T]-?: T[P]; };

실전 패턴: 폼 상태 타입

interface UserForm { name: string; email: string; bio: string; } // 각 필드의 에러 상태 생성 type FormErrors<T> = { [K in keyof T]?: string; }; // 각 필드의 터치 상태 생성 type FormTouched<T> = { [K in keyof T]: boolean; }; // 사용법 const errors: FormErrors<UserForm> = { email: "이메일 형식이 올바르지 않습니다", }; const touched: FormTouched<UserForm> = { name: true, email: true, bio: false, };

폼 인터페이스 하나 만들어두면 에러 상태랑 터치 상태가 자동으로 따라와요. UserForm에 필드 하나 추가하면 FormTouched에도 빠짐없이 넣으라고 TS가 잡아줘요.

템플릿 리터럴 타입: 타입 레벨의 문자열 조작

TypeScript는 타입 시스템에서 문자열을 조작할 수 있어요:

type EventName<T extends string> = `on${Capitalize<T>}`; type ClickEvent = EventName<"click">; // "onClick" type FocusEvent = EventName<"focus">; // "onFocus" type SubmitEvent = EventName<"submit">; // "onSubmit"

실전 패턴: 타입 세이프 라우트 빌더

type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? Param : never; type Params = ExtractParams<"/users/:userId/posts/:postId">; // "userId" | "postId" function buildUrl<T extends string>( template: T, params: Record<ExtractParams<T>, string> ): string { return Object.entries(params).reduce( (url, [key, value]) => url.replace(`:${key}`, value as string), template as string ); } buildUrl("/users/:userId/posts/:postId", { userId: "123", postId: "456", }); // ✅ buildUrl("/users/:userId/posts/:postId", { userId: "123", // ❌ 에러: 'postId' 누락 });

TS가 URL 템플릿을 파싱해서 파라미터 빠뜨리면 바로 에러를 던져요.

satisfies 연산자: 타입 확인하되 넓히지 않기

TypeScript 4.9에서 나온 satisfies는 값이 타입에 맞는지 체크하면서도 추론된 타입을 그대로 살려둬요:

type Color = "red" | "green" | "blue"; type ColorMap = Record<Color, string | number[]>; // satisfies 없이 — 타입이 넓어짐 const colors1: ColorMap = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", }; colors1.red.toUpperCase(); // ❌ 에러: number[]일 수도 있음 // satisfies로 — 리터럴 타입 유지 const colors2 = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", } satisfies ColorMap; colors2.red.toUpperCase(); // ✅ string인 걸 앎 colors2.green.map((c) => c * 2); // ✅ number[]인 걸 앎

satisfies는 양쪽 다 먹을 수 있게 해줘요 — 타입 제약도 체크하고, 추론도 정밀하게 유지하고.

제네릭 베스트 프랙티스

1. 타입 파라미터에 설명적인 이름 쓰기

단일 문자 파라미터는 간단한 제네릭에는 괜찮지만, 복잡한 건 이름이 필요해요:

// ❌ 읽기 어려움 function merge<A, B, C>(source: A, override: B, defaults: C): A & B & C; // ✅ 훨씬 명확 function merge< TSource, TOverride, TDefaults, >(source: TSource, override: TOverride, defaults: TDefaults): TSource & TOverride & TDefaults;

2. 필요 없으면 제네릭 쓰지 않기

// ❌ 불필요한 제네릭 — T가 의미있게 안 쓰임 function greet<T extends string>(name: T): string { return `Hello, ${name}!`; } // ✅ 그냥 타입 직접 쓰기 function greet(name: string): string { return `Hello, ${name}!`; }

타입 파라미터가 시그니처에서 한 번만 나타나면 제네릭이 필요 없을 가능성이 높아요.

3. 제네릭 파라미터 기본값 쓰기

interface ApiResponse<T = unknown> { data: T; status: number; } // T 지정 없이 사용 가능 const response: ApiResponse = { data: null, status: 200 }; // 명시적으로 지정도 가능 const userResponse: ApiResponse<User> = { data: user, status: 200 };

4. assert 말고 constraint 쓰기

// ❌ 'as'로 타입 강제 function getProperty(obj: any, key: string) { return obj[key] as any; } // ✅ 제네릭 + 제약 조건 사용 function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: "Alice", age: 30 }; getProperty(user, "name"); // ✅ string 리턴 getProperty(user, "age"); // ✅ number 리턴 getProperty(user, "email"); // ❌ 에러: "email"은 keyof user에 없음

흔한 실수와 해결법

실수 1: 제네릭 과잉 엔지니어링

// ❌ 읽을 수 없고 불필요 type OverEngineered<T extends object, K extends keyof T, V extends T[K]> = { key: K; value: V; original: T; }; // ✅ 같은 일을 하는 더 간단한 버전 type PropertyEntry<T extends object> = { [K in keyof T]: { key: K; value: T[K]; original: T }; }[keyof T];

실수 2: 유니온에서 조건부 타입 분배 잊기

조건부 타입은 유니온에 대해 분배돼요:

type ToArray<T> = T extends any ? T[] : never; type Result = ToArray<string | number>; // string[] | number[] ((string | number)[]이 아님!)

분배를 막으려면 양쪽을 튜플로 감싸면 돼요:

type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; type Result = ToArrayNonDist<string | number>; // (string | number)[]

실수 3: const 타입 파라미터 안 쓰기

TypeScript 5.0+은 리터럴 타입 보존을 위한 const 타입 파라미터를 지원해요:

// const 없이 — 리터럴이 넓어짐 function createConfig<T>(config: T) { return config; } const c1 = createConfig({ mode: "production" }); // { mode: string } — string으로 넓어짐 // const로 — 리터럴 유지 function createConfig<const T>(config: T) { return config; } const c2 = createConfig({ mode: "production" }); // { mode: "production" } — 리터럴 타입 보존 ✅

전부 합치기: 타입 세이프 쿼리 빌더

실전으로 하나 만들어볼게요. 여러 제네릭 패턴을 조합한 타입 세이프 쿼리 빌더예요:

interface Schema { users: { id: string; name: string; email: string; age: number; role: "admin" | "user"; }; posts: { id: string; title: string; content: string; authorId: string; published: boolean; }; } type WhereClause<T> = { [K in keyof T]?: T[K] | { gt?: T[K]; lt?: T[K]; eq?: T[K] }; }; class QueryBuilder< TSchema extends Record<string, Record<string, any>>, TTable extends keyof TSchema = keyof TSchema, > { private table: TTable | null = null; private selectedFields: (keyof TSchema[TTable])[] = []; from<T extends keyof TSchema>(table: T): QueryBuilder<TSchema, T> { const qb = new QueryBuilder<TSchema, T>(); (qb as any).table = table; return qb; } select<K extends keyof TSchema[TTable]>( ...fields: K[] ): QueryBuilder<TSchema, TTable> { this.selectedFields = fields; return this; } where(clause: WhereClause<TSchema[TTable]>): QueryBuilder<TSchema, TTable> { return this; } build(): string { const fields = this.selectedFields.length > 0 ? this.selectedFields.join(", ") : "*"; return `SELECT ${fields} FROM ${String(this.table)}`; } } const db = new QueryBuilder<Schema>(); // 완벽한 타입 안전성 — 자동완성이 전부 동작 db.from("users") .select("name", "email") .where({ role: "admin", age: { gt: 18 } }); db.from("posts") .select("title", "published") .where({ published: true }); // ❌ 이것들은 전부 컴파일 타임 에러: // db.from("users").select("title"); // users에 'title' 없음 // db.from("posts").where({ role: "admin" }); // posts에 'role' 없음 // db.from("invalid"); // 스키마에 'invalid' 없음

이게 제네릭이에요. 클래스 하나로 스키마 전체 테이블을 커버하면서, 없는 컬럼 select 하면 에러, 엉뚱한 필드로 where 걸면 에러. 전부 빌드할 때 잡아주고, 런타임 비용은 제로.

언제 제네릭을 써야 할까

제네릭이 빛나는 경우:

  • 범용 유틸을 만들 때 — 여러 가지 데이터 형태에서 돌려 쓸 함수, 클래스, 타입
  • 입력이 출력을 결정할 때 — 넣는 타입에 따라 나오는 타입이 달라져야 할 때
  • 라이브러리/API를 만들 때 — 쓰는 사람이 자기 타입을 넘기는 구조
  • any 없애고 싶을 때 — 거의 항상 제네릭이 더 나은 선택

제네릭 안 써도 되는 경우:

  • 구체 타입이면 충분할 때User만 다루면 그냥 User로 박으면 됨
  • 타입이 흘러가지 않을 때T가 파라미터에만 있고 리턴에 안 나오면 의미 없음
  • 읽기 힘들어질 때 — 같이 일하는 사람이 이해 못하면 단순하게 바꾸기

마무리

제네릭은 코드가 복잡해질 때 갖다 붙이는 별도 기능이 아니에요. TypeScript 타입 시스템 자체를 굴리는 핵심 엔진이에요. Array<T>, Promise<T>, Record<K, V> — 싹 다 제네릭이거든요.

멘탈 모델은 단순해요: 제네릭은 타입의 함수. 타입 인풋을 받아서 제약이랑 조건 거쳐서 타입 아웃풋을 뱉어내는 거. 이게 몸에 배면 문법 외우는 단계를 넘어서 패턴이 눈에 들어오기 시작해요.

처음엔 간단하게 가요: 타입 파라미터 하나, 제약 하나. 조건부 타입이나 맵드 타입은 문제가 요구할 때 올라가면 돼요. 그리고 매번 스스로 물어봐요 — "여기 그냥 구체 타입 박으면 안 돼?" 된다면 제네릭은 패스. 최고의 타입 코드는 필요한 정보만 딱 살리는 가장 단순한 코드니까요.

TypeScript 타입 시스템은 그냥 린터가 아니에요. 언어 안의 언어예요. 제네릭은 거기서 프로그래밍하는 방법이고요.

TypeScriptgenericstype systemweb developmentJavaScriptprogrammingtype safetyadvanced TypeScript

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요