안동민 개발노트

본문 시작

제네릭 유틸리티 함수 구현

pluck·merge·null 제거·조건부 반환 함수를 구현하며 keyof·교차 타입·타입 가드를 재사용 API에 적용합니다.

앞선 절들에서 제네릭의 다양한 측면(제약 조건, 클래스/인터페이스, 조건부 타입)을 살펴봤습니다.

이제 이 개념을 묶어 실전에 쓰는 단계로 넘어가겠습니다.

실제 애플리케이션에서 유용한 제네릭 유틸리티 함수를 직접 구현해 보면 제네릭 이해가 훨씬 깊어집니다.

목표는 다양한 타입 데이터를 안전하게 처리하면서 코드 재사용성을 극대화하는 것입니다.

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


특정 속성을 추출하는 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: "[email protected]" },
  { id: 2, name: "Bob", email: "[email protected]" },
  { id: 3, name: "Charlie", email: "[email protected]" },
];

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 함수는 두 개의 제네릭 타입 매개변수 T와 K를 사용합니다.

  • T: 배열 array의 요소인 객체의 타입입니다.
  • K extends keyof T: K는 T 타입 객체의 속성 이름들 중 하나여야 한다는 제약 조건을 가집니다. 이를 통해 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 object와 U extends object라는 제약 조건을 사용합니다.

이는 obj1과 obj2가 반드시 객체 타입이어야 함을 보장하여, 스프레드 문법(...)을 안전하게 사용할 수 있도록 합니다.

반환 타입 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에서 null과 undefined를 제거한 타입을 의미합니다.

filter 내부 콜백의 item is NonNullable<T>는, 이 함수가 true를 반환할 때 item이 NonNullable<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는 각 반환값이 조건부 반환 타입과 일치한다는 검사를 우회하므로, 그 일치 여부는 작성자가 확인해야 합니다. 단언을 줄이려면 입력별 오버로드와 구현의 반환 범위를 함께 설계할 수 있습니다.

아래 그림은 pluck, compact, 병합 함수의 개선 계약입니다. 위 원문은 가변 배열 매개변수와 T & U를 사용하지만, 그림은 읽기 전용 입력과 오른쪽 우선 충돌 정책을 반영한 병합 타입을 제안합니다. 외부 값 검증과 as any 제거도 설계 기준이며 위 코드에 구현되어 있다는 뜻은 아닙니다.

제네릭 유틸리티의 런타임·타입 계약

UTILITY CONTRACTS · DATA FLOW

제네릭 유틸리티의 런타임·타입 계약

readonly 입력은 pluck, nullish compact, right-wins merge의 서로 다른 런타임 연산을 거치며 각 출력 타입이 같은 사실을 말해야 합니다.

제네릭 유틸리티의 런타임·타입 계약 readonly collection과 object 입력이 pluck, nullish compact, right-wins merge의 세 경로로 처리되고 key value, NonNullable, RightWins 결과를 만듭니다. READONLY INPUT컬렉션 또는 객체검증된 도메인 값PLUCK키 값 추출K extends keyof TCOMPACTnullish 제거0 · false · 빈 문자열 보존MERGE오른쪽 우선 병합Omit<L,keyof R> & Rsingle keynull / undefinedcollisionTHE GENERIC SIGNATURE MUST MIRROR THE JAVASCRIPT OPERATION
  1. 검증된 입력

    외부 unknown은 먼저 런타임 검증을 통과합니다.

  2. pluck

    존재하는 단일 키와 T[K] 결과 관계를 보존합니다.

  3. compact

    null과 undefined만 제거하고 다른 falsy 값은 남깁니다.

  4. mergeRight

    object spread의 뒤 객체 우선 충돌 정책을 결과 타입에 반영합니다.

짧은 utility마다 한 불변식만 소유하게 하고 조건부 반환을 as any로 숨기지 않습니다.


제네릭 유틸리티 함수 구현은 제네릭, 제약 조건, 인덱스 타입, 조건부 타입, 타입 가드 같은 고급 기능을 통합적으로 이해하는 데 큰 도움이 됩니다.

이런 함수는 코드 반복을 줄이고, 다양한 시나리오에 유연하게 대응하며, 무엇보다 런타임 오류 가능성을 낮추는 타입 안전한 코드를 작성하도록 도와줍니다.

실제 프로젝트에서 마주치는 문제를 제네릭으로 범용적으로 해결해 보세요.

유틸리티 함수는 짧게 만들수록 좋지만, 타입 매개변수가 너무 많아지면 호출부가 오히려 읽기 어려워집니다.

제네릭 유틸리티는 타입 안전성과 호출부 단순성 사이의 균형이 핵심이므로, 함수가 해결하는 문제와 타입 장치가 늘어나는 이유를 함께 검토해야 합니다.