1. 개요

<T> 를 처음 보면 그냥 “타입 변수구나” 하고 넘어가기 쉽다. 그런데 막상 직접 함수에 제네릭을 걸려고 하면 어디에 <T> 를 써야 하는지, extends 는 왜 필요한지 헷갈린다. 그러다 보면 “일단 any 로 하고 나중에 고치자” 로 도망가는 경우가 많다. 이번 편은 제네릭이 실제로 어떤 문제를 푸는 도구인지, 그리고 any 로 도망가면 뭘 잃는지를 짚는다.


2. 핵심 내용

2-1. 제네릭이 없으면 생기는 문제

배열의 첫 번째 요소를 돌려주는 함수를 만든다고 하자. 타입을 특정하면 이렇게 된다.

function firstString(arr: string[]): string {
  return arr[0]
}

숫자 배열에도 쓰고 싶으면 타입을 또 만들어야 한다. any 로 뚫으면 재사용은 되지만 정보를 다 잃는다.

function first(arr: any[]): any {
  return arr[0]
}
 
const x = first([1, 2, 3])
x.toUpperCase() // 컴파일은 통과하지만 런타임에서 터진다. x 는 number 다

any 를 쓰는 순간 arr 에 뭐가 들어있든 반환값도 any 다. 타입 체커가 이 함수에 대해 아무것도 보장해주지 않는다는 뜻이다.

2-2. 제네릭: 타입을 나중에 정하되, 관계는 고정한다

제네릭은 “지금은 어떤 타입인지 모르지만, 입력과 출력의 관계는 고정한다” 는 도구다.

function first<T>(arr: T[]): T {
  return arr[0]
}
 
const a = first([1, 2, 3]) // T 는 number 로 추론됨, a: number
const b = first(["x", "y"]) // T 는 string 으로 추론됨, b: string
 
a.toUpperCase() // 컴파일 에러: number 에는 없음. 여기서 바로 잡힌다

any 버전과 다른 점은 “입력이 T[] 면 출력은 반드시 T” 라는 관계를 TypeScript 가 기억한다는 것이다. 호출할 때마다 T 자리에 실제 타입이 대입되고, 그 뒤로는 평범한 타입 체크가 그대로 적용된다. any 는 이 관계 자체를 지워버리는 것이고, 제네릭은 관계는 유지한 채 구체적인 타입만 미뤄두는 것이다.

2-3. extends 로 제약 걸기

제네릭이 아무 타입이나 다 받아도 되는 건 아니다. “T 는 뭐든 상관없지만 최소한 length 프로퍼티는 있어야 한다” 처럼 제약이 필요할 때가 있다.

function logLength<T extends { length: number }>(item: T): T {
  console.log(item.length)
  return item
}
 
logLength("hello") // OK, string 은 length 있음
logLength([1, 2, 3]) // OK, 배열도 length 있음
logLength(42) // Error: number 에는 length 없음

여기서 extends 는 클래스 상속이 아니라 “이 모양을 만족해야 한다” 는 구조적 제약이다. 1편에서 본 구조적 타이핑이 여기서도 그대로 적용된다.

키 기반 제약도 자주 쓴다.

function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}
 
const user = { name: "junho", age: 30 }
getProp(user, "name") // string
getProp(user, "age") // number
getProp(user, "email") // Error: "email" 은 user 의 키가 아니다

K extends keyof T 덕분에 존재하지 않는 키를 넘기면 컴파일 시점에 걸린다. 오타로 잘못된 필드명을 넘기는 실수를 여기서 잡는다.

2-4. 흔히 any 로 도망치는 지점: 제네릭 함수를 감쌀 때

콜백을 받아서 실행하고 결과를 캐싱하는 함수를 만든다고 하자. 제네릭 없이 만들면 어떤 함수든 받아야 하니 파라미터와 반환값을 다 any 로 두기 쉽다.

// 도망친 버전
function memoize(fn: (...args: any[]) => any) {
  const cache = new Map<string, any>()
  return (...args: any[]) => {
    const key = JSON.stringify(args)
    if (!cache.has(key)) cache.set(key, fn(...args))
    return cache.get(key)
  }
}
 
const add = memoize((a: number, b: number) => a + b)
const bad = add("1", "2") // 문자열을 넘겨도 컴파일 에러가 안 난다

제네릭으로 함수 시그니처 자체를 타입 변수로 받으면 이 구멍이 막힌다.

function memoize<Args extends unknown[], R>(fn: (...args: Args) => R) {
  const cache = new Map<string, R>()
  return (...args: Args): R => {
    const key = JSON.stringify(args)
    if (!cache.has(key)) cache.set(key, fn(...args))
    return cache.get(key)!
  }
}
 
const add = memoize((a: number, b: number) => a + b)
add("1", "2") // Error: string 은 number 에 대입 불가

fn 이 어떤 함수든 될 수 있다는 걸 제네릭 Args, R 로 표현했을 뿐, any 는 한 군데도 안 남았다. “타입을 모른다” 와 “타입을 신경 안 쓴다” 는 다르다. 제네릭은 전자, any 는 후자다.

2-5. 기본값이 있는 제네릭

제네릭에도 기본 타입을 줄 수 있다. 대부분 명시 안 해도 되지만 명시하고 싶을 때 편의를 준다.

interface ApiResponse<T = unknown> {
  status: number
  data: T
}
 
const raw: ApiResponse = { status: 200, data: "아직 모름" } // T 는 unknown
const typed: ApiResponse<{ id: number }> = { status: 200, data: { id: 1 } }

3. 마무리

요약

  • 제네릭은 구체적인 타입은 미루되, 입력과 출력 사이의 관계는 고정한다. any 는 그 관계 자체를 지운다.
  • T extends 모양 은 상속이 아니라 “이 구조를 최소한 만족해야 한다” 는 제약이다. K extends keyof T 로 존재하는 키만 받게 만들 수 있다.
  • 콜백을 감싸는 유틸리티 함수에서 파라미터·반환 타입을 any 로 퉁치기 쉬운데, 함수 시그니처 자체를 제네릭으로 받으면 타입 안전성을 유지한 채 재사용할 수 있다.
  • “타입을 아직 모른다”(제네릭)와 “타입 체크를 포기한다”(any)는 다른 이야기다.

다음은 4편이다. Partial, Pick, Omit, Record 를 언제 쓰는지 다룬다.