devlog.

TypeScript 제네릭 완전 정복

·8분 읽기

TypeScript를 쓰다 보면 제네릭(Generics)을 피해갈 수 없습니다. 처음에는 <T>가 낯설게 느껴지지만, 제네릭을 이해하면 타입 안전성을 유지하면서도 재사용 가능한 코드를 작성할 수 있습니다.

제네릭이 필요한 이유#

제네릭 없이 다양한 타입을 처리하려면 어떻게 해야 할까요?

// any를 쓰면 타입 안전성을 잃습니다
function identity(arg: any): any {
  return arg
}

const result = identity(42)
result.toUpperCase() // 런타임 에러! TypeScript가 잡아주지 못함

제네릭을 사용하면 타입 안전성을 유지할 수 있습니다.

function identity<T>(arg: T): T {
  return arg
}

const num = identity(42)        // T는 number로 추론
const str = identity('hello')   // T는 string으로 추론

num.toUpperCase() // ✅ TypeScript가 오류를 잡아줍니다
str.toUpperCase() // ✅ 정상 동작

기본 문법#

// 함수 제네릭
function firstItem<T>(arr: T[]): T | undefined {
  return arr[0]
}

// 화살표 함수 제네릭 (TSX 파일에서는 <T,> 로 작성)
const firstItem = <T>(arr: T[]): T | undefined => arr[0]

// 클래스 제네릭
class Stack<T> {
  private items: T[] = []

  push(item: T): void {
    this.items.push(item)
  }

  pop(): T | undefined {
    return this.items.pop()
  }
}

const stack = new Stack<number>()
stack.push(1)
stack.push(2)
stack.pop() // 2

제약 조건 (Constraints)#

extends를 사용해 제네릭에 제약을 걸 수 있습니다.

// T는 length 프로퍼티를 가져야 함
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length
}

getLength('hello')   // ✅ 문자열은 length가 있음
getLength([1, 2, 3]) // ✅ 배열은 length가 있음
getLength(42)        // ❌ 숫자는 length가 없음

// 다른 타입 파라미터로 제약
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { name: '홍길동', age: 30 }
getProperty(user, 'name') // ✅ '홍길동'
getProperty(user, 'email') // ❌ user에 email 프로퍼티 없음

여러 타입 파라미터#

function zip<T, U>(arr1: T[], arr2: U[]): [T, U][] {
  return arr1.map((item, i) => [item, arr2[i]])
}

const result = zip([1, 2, 3], ['a', 'b', 'c'])
// [[1, 'a'], [2, 'b'], [3, 'c']]

실전 예제: API 응답 처리#

실무에서 가장 자주 쓰이는 패턴입니다.

interface ApiResponse<T> {
  data: T
  status: number
  message: string
}

interface User {
  id: number
  name: string
  email: string
}

interface Post {
  id: number
  title: string
  content: string
}

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

// 사용 시 타입이 자동으로 추론됨
const userResponse = await fetchData<User>('/api/users/1')
userResponse.data.name  // ✅ string으로 타입 추론

const postResponse = await fetchData<Post>('/api/posts/1')
postResponse.data.title // ✅ string으로 타입 추론

내장 유틸리티 타입#

TypeScript는 제네릭을 활용한 유틸리티 타입을 제공합니다.

Partial / Required#

interface User {
  id: number
  name: string
  email: string
}

// 모든 프로퍼티를 선택적으로
type PartialUser = Partial<User>
// { id?: number; name?: string; email?: string }

// 모든 프로퍼티를 필수로
type RequiredUser = Required<PartialUser>
// { id: number; name: string; email: string }

Pick / Omit#

// 특정 프로퍼티만 선택
type UserPreview = Pick<User, 'id' | 'name'>
// { id: number; name: string }

// 특정 프로퍼티를 제외
type UserWithoutId = Omit<User, 'id'>
// { name: string; email: string }

Record#

// 키-값 매핑 타입 생성
type UserMap = Record<string, User>
// { [key: string]: User }

const users: UserMap = {
  'user-1': { id: 1, name: '홍길동', email: 'hong@example.com' },
  'user-2': { id: 2, name: '김철수', email: 'kim@example.com' },
}

ReturnType / Parameters#

function createUser(name: string, age: number): User {
  return { id: Math.random(), name, email: '' }
}

// 함수 반환 타입 추출
type CreatedUser = ReturnType<typeof createUser> // User

// 함수 파라미터 타입 추출
type CreateUserParams = Parameters<typeof createUser> // [string, number]

유틸리티 타입 비교#

유틸리티 타입설명예시
Partial<T>모든 프로퍼티를 선택적으로업데이트 요청 DTO
Required<T>모든 프로퍼티를 필수로완전한 엔티티
Pick<T, K>특정 프로퍼티만 선택목록 조회 응답
Omit<T, K>특정 프로퍼티를 제외생성 요청 DTO (id 제외)
Record<K, T>키-값 매핑캐시, 맵 자료구조
Readonly<T>모든 프로퍼티를 읽기 전용으로불변 설정값

마무리#

제네릭은 처음에 어렵게 느껴지지만, 핵심은 간단합니다. 타입을 변수처럼 다루는 것이죠. any 대신 제네릭을 사용하면 타입 안전성을 유지하면서 재사용 가능한 코드를 작성할 수 있습니다. 특히 API 응답 처리, 유틸리티 함수, 공통 컴포넌트에서 제네릭을 적극 활용해보세요.

관련 포스트