제네릭 유틸리티 함수 구현
앞선 절들에서 제네릭의 다양한 측면(제약 조건, 클래스/인터페이스, 조건부 타입)을 살펴보았습니다. 이러한 개념들을 종합하여 실제 애플리케이션에서 유용하게 사용할 수 있는 제네릭 유틸리티 함수를 직접 구현하는 것은 제네릭에 대한 이해를 심화시키는 데 매우 중요합니다. 제네릭 유틸리티 함수는 다양한 타입의 데이터를 안전하게 처리하면서도 코드의 재사용성을 극대화하는 데 목적을 둡니다.
이 절에서는 몇 가지 실용적인 제네릭 유틸리티 함수들을 구현하고, 그 과정에서 제네릭의 여러 기능들이 어떻게 결합되는지 알아보겠습니다.
특정 속성을 추출하는 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
함수는 두 개의 제네릭 타입 매개변수 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>
는 TypeScript에게 이 함수가 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
를 사용하여 타입스크립트 컴파일러에게 "이 시점에는 타입이 정확히 무엇인지 내가 알고 있으니, 너는 잠시 간섭하지 말아라"라고 알려줍니다. (실제 프로젝트에서는 as any
를 최소화하는 것이 좋으며, 더 명확한 오버로드 시그니처를 사용할 수도 있습니다.)
제네릭 유틸리티 함수를 구현하는 것은 제네릭, 제약 조건, 인덱스 타입, 조건부 타입, 그리고 타입 가드와 같은 타입스크립트의 고급 기능들을 통합적으로 이해하는 데 큰 도움이 됩니다. 이러한 함수들은 코드의 반복을 줄이고, 다양한 시나리오에 유연하게 대처하며, 무엇보다 런타임 오류 가능성을 줄이는 타입 안전한 코드를 작성할 수 있게 해줍니다. 실제 프로젝트에서 마주치는 다양한 문제들을 제네릭을 활용하여 범용적으로 해결해 보세요.