본문 바로가기

제네릭 유틸리티 함수 구현

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절 인덱스 타입 참조).

읽기 전용 배열도 읽기만 하는 함수에 전달하기

위 pluck은 입력 타입을 T[]로 선언했습니다. 함수가 배열을 바꾸지 않아도 호출 계약은 가변 배열을 요구하므로 readonly 입력은 그대로 전달할 수 없습니다. 배열을 읽기만 하려면 원래 함수와 별도로 아래 변형을 사용합니다.

function pluckReadonly<T, K extends keyof T>(array: readonly T[], key: K): T[K][] {
  return array.map(item => item[key]);
}

const readonlyProducts: readonly Product[] = [
  { id: 'P003', itemName: 'Keyboard', price: 80 },
  { id: 'P004', itemName: 'Monitor', price: 240 },
];
const prices = pluckReadonly(readonlyProducts, 'price');
console.log(prices); // [80, 240]
prices.push(100);
console.log(readonlyProducts.length, prices.length); // 2 3

readonly T[]는 이 매개변수를 통해 원소를 추가·제거하지 못하게 하는 계약입니다. map은 새 배열을 만들므로 반환된 prices에는 값을 추가할 수 있고 입력 배열의 길이는 그대로입니다. 객체 속성을 뽑았다면 새 배열 안의 객체 참조까지 복제되는 것은 아닙니다.


두 객체를 병합하는 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 이후 배열 요소가 정확한 타입으로 추론됩니다.

없음과 거짓처럼 보이는 값을 구별하기

compact의 조건은 null과 undefined만 제외합니다. 0, false, 빈 문자열은 실제 값이므로 남겨야 합니다. 같은 파일에서 아래 결과를 먼저 예상한 뒤 실행해 보세요.

const possiblyEmpty: (number | boolean | string | null | undefined)[] = [
  0, false, '', null, undefined, '완료',
];
console.log(compact(possiblyEmpty));          // [0, false, '', '완료']
console.log(possiblyEmpty.filter(Boolean));  // ['완료']

두 번째 조건은 값이 참으로 변환되는지 검사하므로 세 개의 falsy 값도 버립니다. 불리언 변환이 결측 제거와 같은 연산은 아닙니다. 사용자 정의 타입 가드도 반환 타입만 적는다고 안전해지지 않으며, 실제 참 조건이 선언한 타입과 일치해야 합니다. 이 예제는 두 결측 값을 직접 비교하므로 남은 값에 대한 계약과 실행 결과가 맞습니다.


조건부 반환 타입을 제네릭으로 표현하기

때로는 입력 타입에 따라 반환 타입이 달라지는 함수를 만들어야 할 때가 있습니다.

이를 제네릭과 조건부 타입을 활용하여 더욱 유연하게 구현할 수 있습니다.

/**
 * 주어진 값의 타입을 기반으로 다른 타입의 결과를 반환하는 함수
 * - 만약 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, merge, processValue의 결과와 타입 계약을 비교합니다. 가변 배열 매개변수, 충돌 키가 없는 병합 예제, 조건부 반환의 as any가 가진 한계도 함께 확인합니다.

새 입력에 적용하고 타입과 값을 따로 적기

위 함수 선언들을 사용합니다. 먼저 pluckReadonly(readonlyProducts, 'id')의 반환 타입과 두 원소를 적습니다. 다음으로 compact([0, null, 7, undefined]), processValue('타입'), processValue(0)의 값을 예상해 보세요. 마지막으로 merge({ label: 'A' }, { enabled: false })의 두 속성 값과 두 입력의 키가 겹치는지 확인합니다.

연산과 계약 확인

식별자를 뽑은 결과는 string[]이며 ['P003', 'P004']입니다. compact는 두 결측 값만 제거해 [0, 7]을 만듭니다. 문자열 '타입'은 길이 2인 숫자, 숫자 0은 문자열 '0'으로 바뀝니다. 병합 결과의 label은 'A', enabled는 false이고 키가 겹치지 않아 이 문제의 교차 타입 설명에 맞습니다.

여기서 processValue의 값과 반환 타입이 맞다는 사실은 실제 분기와 결과를 대조해 확인했습니다. as any 때문에 컴파일 성공만으로 구현이 조건부 반환 계약을 지켰다고 결론을 내릴 수는 없습니다. 입력 키를 같은 이름으로 바꾸는 병합은 원래 예제의 조건을 바꾸므로 덮어쓰기 정책과 타입을 다시 검토해야 합니다.

공식 제네릭 설명의 타입 관계와 사용자 정의 타입 가드의 조건을 대조할 수 있습니다.

제네릭 유틸리티의 런타임·타입 계약 — 상태 추적

compact는 원소를 순서대로 검사해 null/undefined만 빼고 0/false/빈 문자열을 보존한다. 현재 원소와 누적 배열의 변화를 직접 추적할 가치가 있다.

실제 pluck은 가변 배열에서 T[K][]를 반환하고 compact는 null과 undefined만 제거하며 merge는 T&U를 반환한다. 조건부 반환의 as any는 검사를 우회한다.

TYPE / RUNTIME / CONTRACT

제네릭의 반환 타입을 실제 연산과 함께 확인한다

pluck은 키 값을 모으고, compact는 null과 undefined를 빼고, merge는 객체를 펼칩니다. 각 함수의 출력과 타입 계약을 짝지어 읽으세요.

제네릭의 반환 타입을 실제 연산과 함께 확인한다분기는 compact가 원소 하나를 검사하는 흐름입니다. 제외와 보존을 결정한 뒤 다음 원소도 같은 방식으로 검사하며, 결과 배열에는 보존한 원소만 원래 순서로 남습니다.예아니오배열의 원소null · undefined또는 실제 값결측 값?제외결과에 넣지 않음보존0 · false빈 문자열결과 배열보존된 원소원래 순서
제네릭의 반환 타입을 실제 연산과 함께 확인한다분기는 compact가 원소 하나를 검사하는 흐름입니다. 제외와 보존을 결정한 뒤 다음 원소도 같은 방식으로 검사하며, 결과 배열에는 보존한 원소만 원래 순서로 남습니다.예아니오배열의 원소null · undefined또는 실제 값결측 값?제외결과에 넣지 않음보존0 · false빈 문자열결과 배열보존된 원소원래 순서

분기는 compact가 원소 하나를 검사하는 흐름입니다. 제외와 보존을 결정한 뒤 다음 원소도 같은 방식으로 검사하며, 결과 배열에는 보존한 원소만 원래 순서로 남습니다.

PLUCK

키 값을 새 배열로 모은다

pluck(users, "name") → [Alice, Bob, Charlie], "id" → [1,2,3]입니다. K extends keyof T와 T[K][]가 선택한 키의 타입을 연결합니다.

COMPACT

null과 undefined만 제거

[1,null,2,undefined,3,0,4] → [1,2,3,0,4]입니다. item !== null && item !== undefined는 0을 보존하며 false와 빈 문자열도 남깁니다.

MERGE

오른쪽 값이 덮어쓴다

본문은 겹치지 않는 키를 병합해 Alice, dark, true를 읽습니다. T & U는 충돌 키에 대한 오른쪽 우선 정책을 정확히 표현한다고 보장하지 않습니다.

PROCESSVALUE

반환 단언의 책임

"hello" → 5, 123 → "123", true → true입니다. typeof는 실행 검사를 수행하지만 as any는 값과 조건부 반환 타입의 일치 검사를 우회합니다.

본문의 pluck과 compact는 가변 배열 매개변수를 사용합니다. 읽기 전용 입력 지원이나 충돌을 반영한 병합 타입은 별도의 개선 설계입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 노드·간선·영역 라벨아니요
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
mixedNumbers 입력입력: [1,null,2,undefined,3,0,4] / 결과: [][1,null,2,undefined,3,0,4]를 원래 순서대로 읽는다compact는 nullish 제거이며 truthy 검사와 다르다
원소별 검사제외: null·undefined / 0: 보존null과 undefined는 제외하고 1/2/3/0/4를 보존한다0도 실제 number 값이므로 결과에 남긴다
결과 배열결과: [1,2,3,0,4] / 원본: 변이 없음[1,2,3,0,4]를 반환한다본문 별도 possiblyEmpty는 false/빈 문자열도 보존함을 전체 표에 남긴다

제네릭 유틸리티의 런타임·타입 계약 — 상태 추적

compact는 원소를 순서대로 검사해 null/undefined만 빼고 0/false/빈 문자열을 보존한다. 현재 원소와 누적 배열의 변화를 직접 추적할 가치가 있다.

실제 pluck은 가변 배열에서 T[K][]를 반환하고 compact는 null과 undefined만 제거하며 merge는 T&U를 반환한다. 조건부 반환의 as any는 검사를 우회한다.

TYPE / RUNTIME / CONTRACT

제네릭의 반환 타입을 실제 연산과 함께 확인한다

pluck은 키 값을 모으고, compact는 null과 undefined를 빼고, merge는 객체를 펼칩니다. 각 함수의 출력과 타입 계약을 짝지어 읽으세요.

제네릭의 반환 타입을 실제 연산과 함께 확인한다분기는 compact가 원소 하나를 검사하는 흐름입니다. 제외와 보존을 결정한 뒤 다음 원소도 같은 방식으로 검사하며, 결과 배열에는 보존한 원소만 원래 순서로 남습니다.예아니오배열의 원소null · undefined또는 실제 값결측 값?제외결과에 넣지 않음보존0 · false빈 문자열결과 배열보존된 원소원래 순서
제네릭의 반환 타입을 실제 연산과 함께 확인한다분기는 compact가 원소 하나를 검사하는 흐름입니다. 제외와 보존을 결정한 뒤 다음 원소도 같은 방식으로 검사하며, 결과 배열에는 보존한 원소만 원래 순서로 남습니다.예아니오배열의 원소null · undefined또는 실제 값결측 값?제외결과에 넣지 않음보존0 · false빈 문자열결과 배열보존된 원소원래 순서

분기는 compact가 원소 하나를 검사하는 흐름입니다. 제외와 보존을 결정한 뒤 다음 원소도 같은 방식으로 검사하며, 결과 배열에는 보존한 원소만 원래 순서로 남습니다.

PLUCK

키 값을 새 배열로 모은다

pluck(users, "name") → [Alice, Bob, Charlie], "id" → [1,2,3]입니다. K extends keyof T와 T[K][]가 선택한 키의 타입을 연결합니다.

COMPACT

null과 undefined만 제거

[1,null,2,undefined,3,0,4] → [1,2,3,0,4]입니다. item !== null && item !== undefined는 0을 보존하며 false와 빈 문자열도 남깁니다.

MERGE

오른쪽 값이 덮어쓴다

본문은 겹치지 않는 키를 병합해 Alice, dark, true를 읽습니다. T & U는 충돌 키에 대한 오른쪽 우선 정책을 정확히 표현한다고 보장하지 않습니다.

PROCESSVALUE

반환 단언의 책임

"hello" → 5, 123 → "123", true → true입니다. typeof는 실행 검사를 수행하지만 as any는 값과 조건부 반환 타입의 일치 검사를 우회합니다.

본문의 pluck과 compact는 가변 배열 매개변수를 사용합니다. 읽기 전용 입력 지원이나 충돌을 반영한 병합 타입은 별도의 개선 설계입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 노드·간선·영역 라벨아니요
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
mixedNumbers 입력입력: [1,null,2,undefined,3,0,4] / 결과: [][1,null,2,undefined,3,0,4]를 원래 순서대로 읽는다compact는 nullish 제거이며 truthy 검사와 다르다
원소별 검사제외: null·undefined / 0: 보존null과 undefined는 제외하고 1/2/3/0/4를 보존한다0도 실제 number 값이므로 결과에 남긴다
결과 배열결과: [1,2,3,0,4] / 원본: 변이 없음[1,2,3,0,4]를 반환한다본문 별도 possiblyEmpty는 false/빈 문자열도 보존함을 전체 표에 남긴다

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

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

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

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

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