icon
6장 : 제네릭 심화

제네릭 유틸리티 함수 구현


앞선 절들에서 제네릭의 다양한 측면(제약 조건, 클래스/인터페이스, 조건부 타입)을 살펴보았습니다. 이러한 개념들을 종합하여 실제 애플리케이션에서 유용하게 사용할 수 있는 제네릭 유틸리티 함수를 직접 구현하는 것은 제네릭에 대한 이해를 심화시키는 데 매우 중요합니다. 제네릭 유틸리티 함수는 다양한 타입의 데이터를 안전하게 처리하면서도 코드의 재사용성을 극대화하는 데 목적을 둡니다.

이 절에서는 몇 가지 실용적인 제네릭 유틸리티 함수들을 구현하고, 그 과정에서 제네릭의 여러 기능들이 어떻게 결합되는지 알아보겠습니다.


특정 속성을 추출하는 pluck 함수

객체 배열에서 특정 속성 값들만 뽑아내 새로운 배열을 만드는 함수는 매우 흔하게 사용됩니다. 이 함수를 제네릭으로 구현하여 어떤 객체 타입이든, 어떤 속성 이름이든 타입 안전하게 처리할 수 있도록 해봅시다.

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

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

/**
 * 객체 배열에서 특정 키에 해당하는 값들을 추출하여 새로운 배열을 반환하는 함수
 * @param array T 타입 객체들의 배열
 * @param key T 객체의 속성 이름 (K 타입)
 * @returns K 속성에 해당하는 값들의 배열 (T[K] 타입)
 */
function pluck<T, K extends keyof T>(array: T[], key: K): T[K][] {
  return array.map(item => item[key]);
}

const users: User[] = [
  { id: 1, name: "Alice", email: "alice@example.com" },
  { id: 2, name: "Bob", email: "bob@example.com" },
  { id: 3, name: "Charlie", email: "charlie@example.com" },
];

const userNames = pluck(users, "name"); // userNames의 타입은 string[]
console.log(userNames); // [ 'Alice', 'Bob', 'Charlie' ]

const userIds = pluck(users, "id");     // userIds의 타입은 number[]
console.log(userIds);   // [ 1, 2, 3 ]

// pluck(users, "address"); // Error: 'address' 형식은 'keyof User' 형식에 할당할 수 없습니다.

const products: Product[] = [
  { id: "P001", itemName: "Laptop", price: 1200 },
  { id: "P002", itemName: "Mouse", price: 25 },
];

const productNames = pluck(products, "itemName"); // productNames의 타입은 string[]
console.log(productNames); // [ 'Laptop', 'Mouse' ]

pluck 함수는 두 개의 제네릭 타입 매개변수 TK를 사용합니다.

  • T: 배열 array의 요소인 객체의 타입입니다.
  • K extends keyof T: KT 타입 객체의 속성 이름들 중 하나여야 한다는 제약 조건을 가집니다. 이를 통해 item[key]에 안전하게 접근하고, 컴파일러가 key가 유효한 속성 이름임을 확인할 수 있습니다.
  • 반환 타입은 T[K][]입니다. 이는 T 객체의 K 속성에 해당하는 값들의 배열 타입이 됨을 의미합니다 (5장 4절 "인덱스 타입" 참조).

두 객체를 병합하는 merge 함수

두 개의 다른 객체를 받아 하나의 새로운 객체로 병합하는 함수를 제네릭으로 구현해봅시다. 이 함수는 두 객체의 모든 속성을 포함하는 인터섹션(Intersection) 타입을 반환해야 합니다.

/**
 * 두 객체를 병합하여 새로운 객체를 반환하는 함수
 * @param obj1 첫 번째 객체 (T 타입)
 * @param obj2 두 번째 객체 (U 타입)
 * @returns 병합된 새로운 객체 (T & U 타입)
 */
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 }; // 스프레드 문법으로 객체 병합
}

const userDetails = { name: "Alice", age: 30 };
const userSettings = { theme: "dark", notifications: true };

const mergedUser = merge(userDetails, userSettings);
/*
// mergedUser의 타입은 다음과 같이 추론됩니다.
type mergedUser = {
    name: string;
    age: number;
} & {
    theme: string;
    notifications: boolean;
};
// 최종적으로 { name: string; age: number; theme: string; notifications: boolean; }
*/
console.log(mergedUser.name);         // Alice
console.log(mergedUser.theme);        // dark
console.log(mergedUser.notifications); // true

// merge(123, userSettings); // Error: 'number' 형식은 'object' 형식에 할당할 수 없습니다.

merge 함수는 T extends objectU extends object라는 제약 조건을 사용합니다. 이는 obj1obj2가 반드시 객체 타입이어야 함을 보장하여, 스프레드 문법(...)을 안전하게 사용할 수 있도록 합니다. 반환 타입 T & U는 두 입력 객체의 속성들을 모두 포함하는 인터섹션 타입이 됩니다.


null 또는 undefined가 아닌 값만 필터링하는 함수

배열에서 null 또는 undefined 값을 제거하고, 필터링된 배열의 타입을 정확하게 추론하는 함수를 만들어 봅시다. 이는 조건부 타입타입 가드의 조합을 활용합니다.

/**
 * 배열에서 null 또는 undefined 값을 제외하고, 필터링된 배열을 반환하는 함수
 * @param arr T 타입의 배열 (T는 null 또는 undefined를 포함할 수 있음)
 * @returns NonNullable<T> 타입 요소들의 배열
 */
function compact<T>(arr: (T | null | undefined)[]): NonNullable<T>[] {
  // item is NonNullable<T>는 사용자 정의 타입 가드 역할을 합니다.
  // 이 조건이 true일 경우, item의 타입이 NonNullable<T>임을 보증합니다.
  return arr.filter((item): item is NonNullable<T> => item !== null && item !== undefined);
}

const mixedNumbers = [1, null, 2, undefined, 3, 0, 4];
const cleanedNumbers = compact(mixedNumbers); // cleanedNumbers의 타입은 number[]
console.log(cleanedNumbers); // [ 1, 2, 3, 0, 4 ]

const mixedStrings = ["a", "b", undefined, "c", null, "d"];
const cleanedStrings = compact(mixedStrings); // cleanedStrings의 타입은 string[]
console.log(cleanedStrings); // [ 'a', 'b', 'c', 'd' ]

compact 함수는 NonNullable<T>[]를 반환 타입으로 가집니다. 이는 타입스크립트 내장 유틸리티 타입인 NonNullable을 사용하여, 원본 배열 arr의 요소 타입 T에서 nullundefined가 제거된 타입을 의미합니다. filter 메서드 내부의 콜백 함수에서 item is NonNullable<T>는 TypeScript에게 이 함수가 true를 반환할 때 itemNonNullable<T> 타입임을 알려주는 사용자 정의 타입 가드입니다. 덕분에 filter 이후의 배열 요소들이 정확한 타입으로 추론됩니다.


함수 오버로딩을 제네릭으로 구현하기

때로는 입력 타입에 따라 반환 타입이 달라지는 함수를 만들어야 할 때가 있습니다. 이를 제네릭과 조건부 타입을 활용하여 더욱 유연하게 구현할 수 있습니다.

/**
 * 주어진 값의 타입을 기반으로 다른 타입의 결과를 반환하는 함수
 * - 만약 input이 string이면 string의 길이를 number로 반환
 * - 만약 input이 number이면 number를 string으로 변환하여 반환
 * - 그 외의 타입은 그대로 반환
 */
function processValue<T>(input: T): T extends string ? number : (T extends number ? string : T) {
  if (typeof input === 'string') {
    // string -> number 변환 (타입 단언 필요)
    return input.length as any;
  } else if (typeof input === 'number') {
    // number -> string 변환 (타입 단언 필요)
    return String(input) as any;
  } else {
    // 그 외의 타입은 그대로 반환
    return input as any;
  }
}

const len = processValue("hello");    // len의 타입은 number
console.log(len);                     // 5

const strNum = processValue(123);     // strNum의 타입은 string
console.log(strNum);                  // "123"

const bool = processValue(true);      // bool의 타입은 boolean
console.log(bool);                    // true

// const mixed: number | string | boolean = processValue(Math.random() > 0.5 ? "world" : 456);
// console.log(mixed);

이 함수는 반환 타입에 복잡한 조건부 타입을 사용합니다. T extends string ? number : (T extends number ? string : T). 이 조건부 타입은 입력 T의 타입에 따라 반환 타입을 동적으로 결정합니다. 함수 구현 내부에서는 런타임에 typeof 연산자를 사용하여 실제 타입을 확인하고, 적절한 변환을 수행한 후 as any를 사용하여 타입스크립트 컴파일러에게 "이 시점에는 타입이 정확히 무엇인지 내가 알고 있으니, 너는 잠시 간섭하지 말아라"라고 알려줍니다. (실제 프로젝트에서는 as any를 최소화하는 것이 좋으며, 더 명확한 오버로드 시그니처를 사용할 수도 있습니다.)


제네릭 유틸리티 함수를 구현하는 것은 제네릭, 제약 조건, 인덱스 타입, 조건부 타입, 그리고 타입 가드와 같은 타입스크립트의 고급 기능들을 통합적으로 이해하는 데 큰 도움이 됩니다. 이러한 함수들은 코드의 반복을 줄이고, 다양한 시나리오에 유연하게 대처하며, 무엇보다 런타임 오류 가능성을 줄이는 타입 안전한 코드를 작성할 수 있게 해줍니다. 실제 프로젝트에서 마주치는 다양한 문제들을 제네릭을 활용하여 범용적으로 해결해 보세요.