deep-dive2025-02-17·9 min·195/348

TypeScript Generics 활용 패턴: 타입 안전한 재사용 가능한 코드

TypeScript Generics의 다양한 활용 패턴과 실용적인 사용 사례를 설명합니다.

TypeScript Generics 활용 패턴

Introduction

TypeScript Generics는 타입을 매개변수로 전달하여 재사용 가능한 타입 안전한 코드를 작성할 수 있게 해주는 강력한 기능입니다. 함수, 클래스, 인터페이스 등에서 다양한 타입으로 동작하는 범용적인 코드를 구현할 수 있습니다. 이 글에서는 Generics의 기본 개념부터 고급 활용 패턴까지 다루겠습니다.

Environment

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": false
  }
}
npx tsc --version
# 5.3.3

Problem

Generics 없이 작성된 코드의 한계:

// any 타입 사용 - 타입 안전성 없음
function identity(value: any): any {
  return value;
}

const result = identity('hello');
// result의 타입이 any가 되어 타입 정보 유실

// 구체 타입별 함수 중복
function getFirstNumber(arr: number[]): number | undefined {
  return arr[0];
}

function getFirstString(arr: string[]): string | undefined {
  return arr[0];
}

function getFirstBoolean(arr: boolean[]): boolean | undefined {
  return arr[0];
}
// 동일한 로직을 타입별로 반복 작성
// 타입 단언에 의존하는 코드
function parseUser(data: unknown) {
  const user = data as { id: number; name: string };
  return user.name.toUpperCase();
  // 런타임 에러 위험
}

Analysis

Generics의 타입 추론 메커니즘을 분석했습니다:

// 기본 Generics 동작
function identity(value: T): T {
  return value;
}

const num = identity(42);        // 타입 추론: number
const str = identity('hello');   // 타입 추론: string

// 제약 조건 (Constraints)
interface HasLength {
  length: number;
}

function logLength(value: T): T {
  console.log(value.length);
  return value;
}

logLength('hello');     // OK: string은 length 속성 있음
logLength([1, 2, 3]);   // OK: array는 length 속성 있음
logLength(42);           // Error: number는 length 속성 없음
// keyof 활용
function getProperty(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: 'Joel', age: 34 };
const name = getProperty(user, 'name');  // 타입: string
const age = getProperty(user, 'age');    // 타입: number

Solution

1단계: 기본 Generics 패턴

// 함수 Generics
function wrapInArray(value: T): T[] {
  return [value];
}

function merge(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

// 기본 타입 매개변수
function createDefault(value?: T): T {
  return value ?? ({} as T);
}

// 사용 예
const nums = wrapInArray(42);           // number[]
const merged = merge({ a: 1 }, { b: 2 }); // { a: number; b: number }

2단계: 인터페이스와 클래스 Generics

// 제네릭 인터페이스
interface Repository {
  findById(id: string): Promise;
  findAll(): Promise;
  create(item: Omit): Promise;
  update(id: string, item: Partial): Promise;
  delete(id: string): Promise;
}

// 제네릭 클래스 구현
interface User {
  id: string;
  name: string;
  email: string;
}

class UserRepository implements Repository {
  private users: Map = new Map();

  async findById(id: string): Promise {
    return this.users.get(id) ?? null;
  }

  async findAll(): Promise {
    return Array.from(this.users.values());
  }

  async create(userData: Omit): Promise {
    const id = crypto.randomUUID();
    const user: User = { ...userData, id };
    this.users.set(id, user);
    return user;
  }

  async update(id: string, updates: Partial): Promise {
    const existing = this.users.get(id);
    if (!existing) throw new Error('User not found');
    
    const updated = { ...existing, ...updates };
    this.users.set(id, updated);
    return updated;
  }

  async delete(id: string): Promise {
    return this.users.delete(id);
  }
}

3단계: 고급 Generics 패턴

// 조건부 타입 (Conditional Types)
type IsString = T extends string ? true : false;

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

// 매핑된 타입 (Mapped Types)
type Readonly = {
  readonly [K in keyof T]: T[K];
};

type Partial = {
  [K in keyof T]?: T[K];
};

// 템플릿 리터럴 타입
type EventName = `on${Capitalize}`;

type ClickEvent = EventName<'click'>;  // 'onClick'
type ChangeEvent = EventName<'change'>; // 'onChange'

// 타입 안전한 이벤트 시스템
type EventMap = {
  click: { x: number; y: number };
  change: { value: string };
  submit: { formData: FormData };
};

function on(
  event: K,
  handler: (payload: EventMap[K]) => void
): () => void {
  // 이벤트 리스너 등록
  console.log(`Registered handler for ${event}`);
  return () => {
    console.log(`Unregistered handler for ${event}`);
  };
}

// 타입 안전한 사용
on('click', (payload) => {
  console.log(payload.x, payload.y);  // OK
});

on('change', (payload) => {
  console.log(payload.value);  // OK
  console.log(payload.x);       // Error: 'x'는 'change' 이벤트에 없음
});

4단계: infer 키워드 활용

// infer를 사용한 타입 추출
type ReturnOf = T extends (...args: any[]) => infer R ? R : never;

function getUser() {
  return { id: 1, name: 'Joel' };
}

type UserType = ReturnOf;  // { id: number; name: string }

// 함수 매개변수 타입 추출
type FirstParam = T extends (first: infer P, ...args: any[]) => any ? P : never;

type NameType = FirstParam<(name: string, age: number) => void>;  // string

// 프로미스 언래핑
type Awaited = T extends Promise ? Awaited : T;

type Result = Awaited>>;  // string

Lessons Learned

  1. 타입 추론 활용: TypeScript는 Generics 매개변수를 자동으로 추론하므로 명시적 타입 지정이 불필요한 경우가 많습니다
  2. 제약 조건 설계: extends 키워드로 제약 조건을 설정하여 타입 안전성을 유지하면서 범용성을 확보해야 합니다
  3. Mapped Types 활용: 기존 타입을 변환하는 Mapped Types로 반복적인 타입 정의를 줄일 수 있습니다
  4. 조건부 타입: 조건부 타입으로 복잡한 타입 로직을 표현할 수 있지만, 가독성을 위해 적절한 수준으로 유지해야 합니다
  5. 타입 유틸리티: Partial, Pick, Omit 등의 유틸리티 타입을 적극 활용하면 타입 정의를 효율적으로 관리할 수 있습니다

이 블로그는 외부 스폰서십, 제휴 마케팅 또는 광고 수익을 받지 않습니다.