TypeScript 제네릭으로 타입 관계를 보존하는 방법

TypeScript 제네릭으로 타입 관계를 보존하는 방법

한눈에 보기

제네릭은 모든 타입을 허용한다는 뜻이 아니라 입력으로 결정된 타입에 맞춰 결과 타입도 함께 결정된다는 뜻이다. 타입 매개변수에 extends 제약을 두면 필요한 능력만 요구할 수 있다.

목차

왜 이 문제가 생기는가

함수의 입력과 출력에 각각 별도 타입을 붙이면 둘 사이의 관계가 끊길 수 있다. 제네릭은 호출할 때 정해지는 타입을 여러 위치에서 같은 타입 변수로 사용해 그 관계를 보존한다.

유니온 타입으로 바꾸면 잃는 정보

여러 타입을 받을 수 있다는 이유로 입력과 출력을 각각 유니온으로 작성해 보자.

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

const result = identity("hello");
// result: string | number

호출부에서는 문자열을 넣었다는 사실을 알고 있지만 반환 타입은 다시 string | number가 된다. 함수 선언에서 입력과 출력이 같은 타입이라는 관계가 사라졌기 때문이다.

function identity<T>(value: T): T {
  return value;
}

const text = identity("hello"); // "hello"
const count = identity(3);      // 3

제네릭의 핵심은 “아무 타입이나 허용”이 아니다. 여기서는 입력 타입 T와 반환 타입 T가 같다는 관계를 표현한다. TypeScript는 호출 인자에서 T를 추론하므로 보통 identity<string>(...)처럼 직접 적을 필요도 없다.

타입 매개변수가 두 번 이상 등장하는지 본다

타입 매개변수가 입력, 출력 또는 여러 인자 사이의 관계를 연결한다면 제네릭의 의미가 있다. 한 번만 등장하고 다른 타입과 관계가 없다면 단순한 구체 타입이나 unknown이 더 정확할 수 있다.

두 타입 사이의 관계 표현하기

제네릭 타입 매개변수는 하나일 필요가 없다. 배열의 각 값을 다른 타입으로 바꾸는 함수는 입력 원소와 출력 원소의 관계를 두 타입으로 나타낸다.

function mapValues<Input, Output>(
  items: readonly Input[],
  transform: (item: Input, index: number) => Output,
): Output[] {
  return items.map(transform);
}

const lengths = mapValues(["one", "three"], (word) => word.length);
// number[]

Input은 배열 원소에서 추론되고 Output은 callback 반환값에서 추론된다. 이름을 T, U로 짧게 쓸 수도 있지만 관계가 둘 이상이면 Input, Output, Key, Value처럼 역할을 드러내는 이름이 읽기 쉽다.

두 배열의 같은 위치 값을 묶는 함수는 튜플 관계를 표현할 수 있다.

function zip<Left, Right>(
  left: readonly Left[],
  right: readonly Right[],
): Array<readonly [Left, Right]> {
  const length = Math.min(left.length, right.length);
  const result: Array<readonly [Left, Right]> = [];

  for (let index = 0; index < length; index += 1) {
    result.push([left[index], right[index]]);
  }

  return result;
}

const rows = zip(["A", "B"], [10, 20]);
// Array<readonly [string, number]>

이 코드는 제네릭 관계를 설명하기 위해 만든 예시다. 실제 zip 함수라면 두 배열 길이가 다를 때 자를지, 오류를 낼지 정책도 정해야 한다.

제약 조건과 keyof 함께 사용하기

아무 타입이나 받되 특정 능력은 필요할 수 있다. extends는 상속만을 뜻하는 문법이 아니라 타입 매개변수의 허용 범위를 제한한다.

function indexBy<Item extends { id: PropertyKey }>(
  items: readonly Item[],
): Map<Item["id"], Item> {
  return new Map(items.map((item) => [item.id, item]));
}

호출부의 구체적인 필드는 그대로 보존하면서 id가 있어야 한다는 조건만 요구한다.

객체의 실제 키만 받게 하려면 keyof와 두 타입 매개변수의 관계를 사용한다.

function pluck<Item, Key extends keyof Item>(
  items: readonly Item[],
  key: Key,
): Array<Item[Key]> {
  return items.map((item) => item[key]);
}

const users = [
  { id: 1, name: "Mina", active: true },
  { id: 2, name: "Jin", active: false },
];

const names = pluck(users, "name");   // string[]
const active = pluck(users, "active"); // boolean[]
// pluck(users, "email");             // 컴파일 오류
flowchart LR
    A[Item 타입] --> B[keyof Item]
    B --> C[Key 제약]
    C --> D[반환 Item Key]

제네릭이 오히려 복잡하게 만드는 경우

제네릭을 추가한다고 항상 재사용성이 좋아지는 것은 아니다.

function logValue<T>(value: T): void {
  console.log(value);
}

T가 반환 타입이나 다른 인자와 연결되지 않는다. 이 함수가 값의 타입을 전혀 사용하지 않는다면 unknown이 의도에 더 가깝다.

function logValue(value: unknown): void {
  console.log(value);
}

타입 매개변수가 많고 조건부 타입이 중첩되어 호출부 오류를 읽기 어렵게 만든다면 여러 구체 함수나 명시적인 overload가 더 나을 수 있다.

상황 우선 고려할 표현
입력과 출력 타입이 같음 제네릭 하나
입력을 다른 출력으로 변환 Input, Output 제네릭
아무 값이나 받고 사용하지 않음 unknown
정해진 몇 가지 경우만 허용 유니온 또는 overload
공통 필드가 반드시 필요 extends 제약
객체의 실제 키만 허용 Key extends keyof Item

결론

제네릭의 가치는 여러 타입을 허용하는 데 있지 않고, 호출부에서 정해진 타입 관계를 반환값과 callback까지 보존하는 데 있다. 타입 매개변수가 두 위치 이상의 관계를 설명하는지 확인하고, 필요한 능력만 extends로 제한한다. 단순한 타입 하나를 감추기 위해 제네릭을 추가하기보다 추론 결과가 실제 API 사용성을 개선하는 경우에 사용해야 한다.

관련 노트