DevToolBox무료
블로그

TypeScript 제네릭 완전 가이드: 실전 예제로 배우기

14분 읽기by DevToolBox

TypeScript 제네릭은 타입 시스템에서 가장 강력한 기능 중 하나입니다. 타입을 매개변수화하여 유연하고 재사용 가능하며 타입 안전한 코드를 작성할 수 있습니다. 모든 데이터 타입에 대해 함수나 클래스를 복제할 필요 없이 한 번 작성하면 완전한 타입 안전성을 유지하면서 모든 타입에 사용할 수 있습니다. 이 가이드는 기초부터 고급 패턴까지 모든 것을 다룹니다.

첫 번째 제네릭 함수

전형적인 시작점은 항등 함수입니다. 받은 것을 그대로 반환하는 함수입니다. 제네릭 없이는 any를 사용하여 타입 정보를 잃거나 각 타입에 대해 별도의 함수를 작성해야 합니다.

제네릭을 사용하면 플레이스홀더 역할을 하는 타입 매개변수(관례적으로 T로 명명)를 정의합니다. 함수를 호출하면 TypeScript가 자동으로 실제 타입을 추론합니다.

// Without generics — loses type info
function identityAny(value: any): any {
  return value;
}
const result1 = identityAny("hello"); // type: any (not string!)

// With generics — preserves type info
function identity<T>(value: T): T {
  return value;
}
const result2 = identity("hello");    // type: string ✓
const result3 = identity(42);         // type: number ✓
const result4 = identity(true);       // type: boolean ✓

// Explicit type argument (rarely needed)
const result5 = identity<string>("hello"); // type: string

제네릭 함수

제네릭 함수는 여러 타입 매개변수를 받을 수 있으며 기본 타입도 가질 수 있습니다. 이를 통해 유틸리티 함수 구축에 매우 다양하게 활용됩니다.

// Multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}
const p = pair("hello", 42); // type: [string, number]

// Swap function with two type params
function swap<A, B>(tuple: [A, B]): [B, A] {
  return [tuple[1], tuple[0]];
}
const swapped = swap(["hello", 42]); // type: [number, string]

// Default type parameters
function createArray<T = string>(length: number, value: T): T[] {
  return Array(length).fill(value);
}
const strings = createArray(3, "x");   // T inferred as string
const numbers = createArray(3, 0);     // T inferred as number
const defaults = createArray<>(3, ""); // T defaults to string

기본 타입 매개변수는 기본 함수 매개변수와 동일하게 작동합니다. 호출자가 타입을 지정하지 않으면 기본값이 사용됩니다.

제네릭 인터페이스와 타입

제네릭은 함수에만 국한되지 않습니다. 인터페이스타입 별칭을 매개변수화하여 모든 타입에서 작동하는 유연한 데이터 구조를 만들 수 있습니다.

// Generic interface
interface Box<T> {
  value: T;
  label: string;
}

const stringBox: Box<string> = { value: "hello", label: "greeting" };
const numberBox: Box<number> = { value: 42, label: "answer" };

// Generic interface with multiple params
interface KeyValuePair<K, V> {
  key: K;
  value: V;
}

// Generic type alias
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

const ok: Result<string> = { success: true, data: "hello" };
const fail: Result<string> = { success: false, error: new Error("oops") };

// Generic type for callbacks
type AsyncCallback<T> = (error: Error | null, data: T | null) => void;

// Generic type for nullable values
type Nullable<T> = T | null | undefined;

제네릭 인터페이스는 API 응답, 컬렉션, 데이터 컨테이너의 형태를 정의할 때 특히 유용합니다.

제네릭 클래스

클래스도 제네릭이 될 수 있습니다. 스택, 큐, 연결 리스트 같은 데이터 구조나 여러 타입과 함께 작동해야 하는 컨테이너를 구축할 때 유용합니다.

// Generic Stack class
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 numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.pop(); // type: number | undefined

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.peek(); // type: string | undefined

// Generic class with multiple type params
class HashMap<K, V> {
  private map = new Map<K, V>();

  set(key: K, value: V): void {
    this.map.set(key, value);
  }

  get(key: K): V | undefined {
    return this.map.get(key);
  }

  has(key: K): boolean {
    return this.map.has(key);
  }
}

타입 매개변수는 클래스 전체에서 사용 가능합니다. 속성, 메서드 매개변수, 반환 타입에서 사용할 수 있습니다.

extends를 사용한 제네릭 제약

제약 없는 제네릭은 모든 타입을 받지만, 허용되는 타입을 좁혀야 할 때가 있습니다. extends 키워드로 제약을 추가할 수 있습니다.

keyof로 객체 키 제약

매우 일반적인 패턴은 타입 매개변수를 다른 타입의 유효한 키로 제약하는 것입니다. 이를 통해 타입 안전한 속성 접근이 보장됩니다.

// getProperty with keyof constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30, email: "alice@example.com" };

getProperty(user, "name");  // type: string ✓
getProperty(user, "age");   // type: number ✓
// getProperty(user, "foo"); // Error: "foo" is not assignable to "name" | "age" | "email"

// Pick specific keys from an object
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 subset = pick(user, ["name", "email"]);
// type: { name: string; email: string }

특정 형태로 제약

인터페이스나 객체 타입을 확장하여 타입 매개변수에 특정 속성을 요구할 수 있습니다.

// Constraint to objects with a length property
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(value: T): T {
  console.log(`Length: ${value.length}`);
  return value;
}

logLength("hello");     // OK — string has length
logLength([1, 2, 3]);   // OK — array has length
logLength({ length: 5, name: "test" }); // OK — has length
// logLength(42);       // Error — number has no length property

// Constraint to objects with an id
interface HasId {
  id: number | string;
}

function findById<T extends HasId>(items: T[], id: T["id"]): T | undefined {
  return items.find(item => item.id === id);
}

const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
];

const found = findById(users, 1);
// type: { id: number; name: string } | undefined

조건부 타입

조건부 타입은 T extends U ? X : Y 구문을 사용하여 조건에 따라 두 타입 중 하나를 선택할 수 있습니다. 많은 고급 타입 수준 계산의 기반입니다.

// Basic conditional type
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false
type C = IsString<"hello">; // true

// Flatten arrays, leave other types alone
type Flatten<T> = T extends Array<infer Item> ? Item : T;

type D = Flatten<string[]>;    // string
type E = Flatten<number[][]>;  // number[]
type F = Flatten<string>;      // string (not an array, returned as-is)

// Extract return type (simplified ReturnType)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type G = MyReturnType<() => string>;           // string
type H = MyReturnType<(x: number) => boolean>; // boolean

// Extract promise value
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type I = UnwrapPromise<Promise<string>>; // string
type J = UnwrapPromise<number>;          // number

조건부 타입은 infer와 결합하면 특히 강력해집니다. infer를 사용하면 다른 타입 내부에서 타입을 추출할 수 있습니다.

매핑된 타입

매핑된 타입은 기존 타입의 속성을 변환하여 새 타입을 만듭니다. [K in keyof T] 구문으로 키를 반복합니다.

// Make all properties optional (like Partial<T>)
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Make all properties required (like Required<T>)
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

// Make all properties readonly (like Readonly<T>)
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Make all properties nullable
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

// Transform property types
type Stringify<T> = {
  [K in keyof T]: string;
};

interface User {
  name: string;
  age: number;
  active: boolean;
}

type StringifiedUser = Stringify<User>;
// { name: string; age: string; active: string }

// Mapped type with conditional
type OptionalNullable<T> = {
  [K in keyof T]: undefined extends T[K] ? T[K] | null : T[K];
};

매핑된 타입에서 readonly?(선택적) 같은 수정자를 추가하거나 제거할 수 있습니다.

내장 유틸리티 타입

TypeScript에는 제네릭으로 구축된 많은 유틸리티 타입이 포함되어 있습니다. 가장 많이 사용되는 것들을 소개합니다:

유틸리티 타입설명예시
Partial<T>모든 속성을 선택적으로Partial<User>
Required<T>모든 속성을 필수로Required<Partial<User>>
Pick<T, K>지정 속성 선택Pick<User, "name" | "email">
Omit<T, K>지정 속성 제외Omit<User, "password">
Record<K, V>키-값 타입 매핑 생성Record<string, number>
Readonly<T>모든 속성을 읽기 전용으로Readonly<Config>
ReturnType<T>함수 반환 타입 추출ReturnType<typeof fn>
Parameters<T>함수 매개변수 타입 튜플 추출Parameters<typeof fn>
// Utility types in action
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

// For update forms — all fields optional
type UpdateUser = Partial<User>;

// Public user — omit password
type PublicUser = Omit<User, "password">;

// User creation — omit auto-generated fields
type CreateUser = Omit<User, "id" | "createdAt">;

// Lookup table
type UserLookup = Record<number, User>;

// Immutable user from API
type FrozenUser = Readonly<User>;

// Extract function types
function fetchUser(id: number): Promise<User> { /* ... */ }
type FetchReturn = ReturnType<typeof fetchUser>;     // Promise<User>
type FetchParams = Parameters<typeof fetchUser>;     // [number]

실전 패턴

제네릭 API 응답 래퍼

제네릭의 가장 일반적인 사용 중 하나는 API 응답을 일관된 구조로 래핑하는 것입니다. 모든 엔드포인트가 특정 데이터 타입을 보존하면서 동일한 형식으로 데이터를 반환하도록 보장합니다.

// Generic API response wrapper
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number;
    pageSize: number;
    totalItems: number;
    totalPages: number;
  };
}

// Usage with specific types
interface User {
  id: number;
  name: string;
  email: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
  const res = await fetch(url);
  return res.json();
}

// TypeScript knows the exact data type
const userRes = await fetchApi<User>("/api/users/1");
console.log(userRes.data.name); // type: string ✓

const productsRes = await fetchApi<Product[]>("/api/products");
console.log(productsRes.data[0].price); // type: number ✓

제네릭 React 컴포넌트 Props

제네릭을 사용하면 props의 타입 안전성을 유지하면서 다양한 데이터 타입에서 작동하는 React 컴포넌트를 구축할 수 있습니다.

// Generic list component props
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
  onItemClick?: (item: T) => void;
  emptyMessage?: string;
}

function GenericList<T>({
  items,
  renderItem,
  keyExtractor,
  onItemClick,
  emptyMessage = "No items",
}: ListProps<T>) {
  if (items.length === 0) return <p>{emptyMessage}</p>;
  return (
    <ul>
      {items.map((item, index) => (
        <li
          key={keyExtractor(item)}
          onClick={() => onItemClick?.(item)}
        >
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

// Usage — TypeScript infers T from items
<GenericList
  items={users}
  keyExtractor={(user) => user.id}
  renderItem={(user) => <span>{user.name}</span>}
  onItemClick={(user) => console.log(user.email)}
/>

제네릭 리포지토리 패턴

리포지토리 패턴은 제네릭 인터페이스 뒤에 데이터 접근을 추상화합니다. 비즈니스 로직을 변경하지 않고 데이터 소스를 교체할 수 있습니다.

// Generic repository interface
interface Entity {
  id: number | string;
}

interface Repository<T extends Entity> {
  findAll(): Promise<T[]>;
  findById(id: T["id"]): Promise<T | null>;
  create(data: Omit<T, "id">): Promise<T>;
  update(id: T["id"], data: Partial<T>): Promise<T>;
  delete(id: T["id"]): Promise<boolean>;
}

// Concrete implementation
class ApiRepository<T extends Entity> implements Repository<T> {
  constructor(private baseUrl: string) {}

  async findAll(): Promise<T[]> {
    const res = await fetch(this.baseUrl);
    return res.json();
  }

  async findById(id: T["id"]): Promise<T | null> {
    const res = await fetch(`${this.baseUrl}/${id}`);
    if (!res.ok) return null;
    return res.json();
  }

  async create(data: Omit<T, "id">): Promise<T> {
    const res = await fetch(this.baseUrl, {
      method: "POST",
      body: JSON.stringify(data),
    });
    return res.json();
  }

  async update(id: T["id"], data: Partial<T>): Promise<T> {
    const res = await fetch(`${this.baseUrl}/${id}`, {
      method: "PATCH",
      body: JSON.stringify(data),
    });
    return res.json();
  }

  async delete(id: T["id"]): Promise<boolean> {
    const res = await fetch(`${this.baseUrl}/${id}`, { method: "DELETE" });
    return res.ok;
  }
}

// Usage
interface User extends Entity { id: number; name: string; email: string; }
const userRepo = new ApiRepository<User>("/api/users");
const users = await userRepo.findAll(); // type: User[]

제네릭 이벤트 이미터

타입 안전한 이벤트 이미터는 이벤트 이름과 페이로드가 올바르게 타입화되도록 보장합니다. 알 수 없는 데이터가 있는 문자열 기반 이벤트는 더 이상 필요 없습니다.

// Type-safe event emitter
type EventMap = Record<string, any>;
type EventCallback<T> = (payload: T) => void;

class TypedEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: EventCallback<Events[K]>[];
  } = {};

  on<K extends keyof Events>(event: K, callback: EventCallback<Events[K]>): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
  }

  off<K extends keyof Events>(event: K, callback: EventCallback<Events[K]>): void {
    const cbs = this.listeners[event];
    if (cbs) {
      this.listeners[event] = cbs.filter(cb => cb !== callback);
    }
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    const cbs = this.listeners[event];
    if (cbs) {
      cbs.forEach(cb => cb(payload));
    }
  }
}

// Define your event types
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "item:added": { itemId: string; quantity: number };
  "error": { message: string; code: number };
}

const emitter = new TypedEmitter<AppEvents>();

// Fully typed — payload type is inferred
emitter.on("user:login", (payload) => {
  console.log(payload.userId);    // type: string ✓
  console.log(payload.timestamp); // type: number ✓
});

// Error: "unknown:event" is not assignable to keyof AppEvents
// emitter.on("unknown:event", () => {});

흔한 실수

개발자가 제네릭에서 가장 자주 범하는 실수와 수정 방법:

실수문제수정
제네릭 대신 any 사용모든 타입 정보 손실any 대신 타입 매개변수 T 사용
타입 매개변수 과도한 제약유효한 타입에 대해 제네릭 사용 불가실제로 필요한 것만 제약
불필요한 타입 매개변수한 번만 사용되는 T 추가는 복잡성 증가T가 반환 타입에만 나타나면 구체적 타입 사용
속성 접근 시 제약 미사용TypeScript 오류: 타입 T에 속성이 존재하지 않음extends 제약 추가: T extends { prop: type }
제네릭 기본값과 제약 혼동기본 타입은 제한하지 않음. 호출자가 다른 타입 전달 가능제한에는 extends, 기본값에는 = 사용
추론 가능할 때 추론 미사용TS가 추론할 수 있는데 명시적으로 타입 전달가능하면 TypeScript가 인수에서 타입을 추론하게 하기
readonly T[] 대신 T[] 사용읽기 전용이어야 할 배열의 변경 허용API의 불변 데이터에는 readonly T[] 사용
// ❌ Mistake: Using any
function badParse(json: string): any {
  return JSON.parse(json);
}
const data = badParse('{"name":"Alice"}');
data.whatever; // No error — but might crash at runtime!

// ✓ Correction: Using generics with validation
function safeParse<T>(json: string, validator: (data: unknown) => data is T): T | null {
  try {
    const parsed = JSON.parse(json);
    return validator(parsed) ? parsed : null;
  } catch {
    return null;
  }
}

// ❌ Mistake: Unnecessary type parameter
function badLength<T>(arr: T[]): number {
  return arr.length; // T is never meaningfully used
}

// ✓ Correction: Just use the concrete type
function goodLength(arr: unknown[]): number {
  return arr.length;
}

// ❌ Mistake: Missing constraint
function badGetName<T>(obj: T): string {
  return obj.name; // Error: Property 'name' does not exist on type 'T'
}

// ✓ Correction: Add constraint
function goodGetName<T extends { name: string }>(obj: T): string {
  return obj.name; // OK ✓
}

자주 묻는 질문

제네릭과 any 타입의 차이점은 무엇인가요?

제네릭은 함수나 클래스 전체에서 타입 정보를 보존하지만, any는 이를 버립니다. 제네릭으로 문자열을 전달하면 TypeScript는 반환 타입도 문자열임을 알지만, any에서는 모든 타입 정보가 손실됩니다.

제네릭과 함수 오버로드 중 언제 어떤 것을 사용해야 하나요?

모든 타입에 대해 로직이 동일하고 타입 관계만 보존하면 되는 경우 제네릭을 사용합니다. 입력 타입에 따라 구현이 다르거나 특정 입력 타입을 특정 출력 타입에 매핑해야 할 때는 오버로드를 사용합니다.

TSX 파일에서 화살표 함수에 제네릭을 사용할 수 있나요?

네, 하지만 JSX 모호성을 피하기 위해 후행 쉼표를 추가해야 합니다: const fn = <T,>(arg: T): T => arg. 쉼표가 파서에게 이것이 JSX 태그가 아닌 제네릭임을 알려줍니다.

제네릭에 타입 매개변수가 몇 개여야 하나요?

가능한 적게. 1~2개가 일반적입니다. 3개가 필요한 경우도 있습니다. 3개 이상 필요하면 리팩토링을 고려하세요. 각 타입 매개변수는 명확한 목적이 있어야 합니다.

제네릭이 런타임 성능에 영향을 미치나요?

아닙니다. 제네릭은 순수하게 컴파일 타임 기능입니다. 컴파일 중 완전히 제거되며 런타임 오버헤드가 없습니다. 생성된 JavaScript에는 제네릭 타입 매개변수의 흔적이 없습니다.

타입 매개변수의 명명 규칙은 무엇인가요?

단일 대문자가 관례입니다: T는 타입, K는 키, V는 값, E는 요소, R은 반환 타입. 여러 매개변수가 있는 복잡한 제네릭의 경우 TData, TError 같은 설명적 이름도 허용되며 가독성을 높여줍니다.

𝕏 Twitterin LinkedIn
도움이 되었나요?

최신 소식 받기

주간 개발 팁과 새 도구 알림을 받으세요.

스팸 없음. 언제든 구독 해지 가능.

Try These Related Tools

TSJSON to TypeScriptJSTypeScript to JavaScriptGTGraphQL to TypeScript

Related Articles

JSON에서 TypeScript로: 예제와 함께하는 완벽 가이드

JSON 데이터를 TypeScript 인터페이스로 자동 변환하는 방법을 배웁니다. 중첩 객체, 배열, 선택적 필드, 모범 사례를 다룹니다.

TypeScript vs JavaScript: 언제, 어떻게 변환할까

TypeScript를 JavaScript로, 또는 그 반대로 변환해야 할 시기에 대한 실전 가이드. 마이그레이션 전략, 도구, 번들 크기 영향, 팀 고려사항을 다룹니다.

JSON to Zod Schema: TypeScript 타입 안전 런타임 유효성 검사

JSON을 Zod 스키마로 변환하여 TypeScript에서 타입 안전한 런타임 유효성 검사를 구현하는 방법을 알아보세요.

GraphQL to TypeScript: 코드 생성 가이드

GraphQL 스키마에서 TypeScript 타입 자동 생성.