제네릭 유틸리티 함수 구현
객체 배열에서 속성을 모으는 pluck, 두 객체를 합치는 merge, null과 undefined만 걸러 내는 compact, 입력에 따라 반환 타입이 바뀌는 processValue를 직접 구현하며, 각 함수의 타입이 실제 연산과 맞는지와 단언이 검사를 건너뛰는 지점을 확인합니다.
이 장에서 본 제약 조건과 제네릭 클래스, 조건부 타입은 결국 실제로 동작하는 함수의 타입을 정확하게 적는 데 쓰입니다. 타입이 아무리 정교해도 실행되는 코드가 그 타입과 다르게 동작하면, 컴파일러는 틀린 약속을 믿고 잘못된 코드를 통과시킵니다.
이 글은 자주 쓰는 유틸리티 함수 네 개를 직접 구현하며, 각 함수의 타입이 실제 연산과 맞는지를 출력과 함께 확인합니다. 단언으로 검사를 건너뛴 곳에서는 그 대가도 함께 봅니다. 예제는 ts-first 프로젝트에 generic-utils.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.
속성을 모으는 pluck
pluck은 객체 배열에서 같은 속성의 값만 모아 새 배열을 만드는 함수입니다. 배열의 요소 타입 T와 고를 키 K를 타입 매개변수로 받으면, 결과 배열의 요소 타입을 T[K]로 정확히 적을 수 있습니다.
interface User {
id: number;
name: string;
email: string;
}
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]" },
];
console.log(pluck(users, "name"));
console.log(pluck(users, "id"));[ 'Alice', 'Bob', 'Charlie' ]
[ 1, 2, 3 ]pluck(users, "name")의 결과는 User["name"][], 곧 string[]이고, "id"를 넘기면 number[]입니다. 없는 키를 넘긴 pluck(users, "address")는 Argument of type '"address"' is not assignable to parameter of type 'keyof User'.로 진단됩니다.
그런데 이 pluck은 매개변수를 T[]로 적었기 때문에, 배열을 바꾸지 않는데도 읽기 전용 배열을 받지 못합니다. readonly Product[]를 넘기면 읽기 전용 배열은 바꿀 수 있는 배열에 할당되지 않는다는 진단이 나오므로, 읽기만 하는 함수라면 매개변수를 readonly T[]로 적는 편이 더 많은 입력을 받습니다.
interface Product {
id: string;
itemName: string;
price: number;
}
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");
prices.push(100);
console.log(prices, readonlyProducts.length);[ 80, 240, 100 ] 2readonly T[]는 이 매개변수를 통해 원소를 더하거나 빼지 않겠다는 약속입니다. map은 새 배열을 만들어 돌려주므로, 결과 prices에는 값을 더할 수 있고 원래 배열의 길이는 2 그대로입니다. 다만 객체를 모았다면 새 배열에 담긴 것은 같은 객체의 참조이므로, 객체 자체까지 복사되지는 않습니다.
두 객체를 합치는 merge
merge는 두 객체를 전개 구문으로 합쳐 새 객체를 만듭니다. 6장 1편에서 본 것처럼, 결과 타입 T & U는 두 객체의 키가 겹치지 않을 때에만 실제 값과 맞습니다.
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const mergedUser = merge({ name: "Alice", age: 30 }, { theme: "dark", notifications: true });
console.log(mergedUser.name, mergedUser.theme, mergedUser.notifications);Alice dark true두 객체가 서로 다른 키만 가지므로 mergedUser는 네 속성을 모두 맞는 타입으로 가집니다. merge(123, {})는 숫자가 object가 아니어서 진단되며, 키가 겹칠 수 있는 입력이라면 결과 타입을 Omit<T, keyof U> & U처럼 덮어쓰기를 반영해 다시 적어야 합니다.
비어 있는 값만 걸러 내는 compact
compact는 배열에서 null과 undefined만 빼고 나머지를 남깁니다. filter의 콜백에 5장에서 본 타입 술어를 적으면, 결과 배열의 요소 타입에서도 null과 undefined가 빠집니다.
function compact<T>(arr: readonly (T | null | undefined)[]): NonNullable<T>[] {
return arr.filter((item): item is NonNullable<T> => item !== null && item !== undefined);
}
const mixedNumbers = [1, null, 2, undefined, 3, 0, 4];
console.log(compact(mixedNumbers));
const possiblyEmpty: (number | boolean | string | null | undefined)[] = [0, false, "", null, undefined, "완료"];
console.log(compact(possiblyEmpty));
console.log(possiblyEmpty.filter(Boolean));[ 1, 2, 3, 0, 4 ]
[ 0, false, '', '완료' ]
[ '완료' ]compact(mixedNumbers)에서 T는 number로 추론되어 결과는 number[]이고, 0도 실제 값이므로 남습니다. filter(Boolean)은 값이 참으로 바뀌는지를 검사하므로, 0과 false, 빈 문자열까지 모두 버리고 '완료'만 남깁니다. 비어 있는 값을 빼는 것과 falsy 값을 빼는 것은 다른 연산이며, 타입 술어 item is NonNullable<T>는 콜백이 정말 null과 undefined만 거를 때에만 맞는 약속입니다.
제네릭 유틸리티의 런타임·타입 계약 — 상태 추적
compact는 원소를 순서대로 검사해 null/undefined만 빼고 0/false/빈 문자열을 보존한다. 현재 원소와 누적 배열의 변화를 직접 추적할 가치가 있다.
TYPE / RUNTIME / CONTRACT
제네릭의 반환 타입을 실제 연산과 함께 확인한다
pluck은 키 값을 모으고, compact는 null과 undefined를 빼고, merge는 객체를 펼칩니다. 각 함수의 출력과 타입 계약을 짝지어 읽으세요.
분기는 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/빈 문자열을 보존한다. 현재 원소와 누적 배열의 변화를 직접 추적할 가치가 있다.
TYPE / RUNTIME / CONTRACT
제네릭의 반환 타입을 실제 연산과 함께 확인한다
pluck은 키 값을 모으고, compact는 null과 undefined를 빼고, merge는 객체를 펼칩니다. 각 함수의 출력과 타입 계약을 짝지어 읽으세요.
분기는 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가 [1, null, 2, undefined, 3, 0, 4]의 원소를 앞에서부터 하나씩 검사해, 비어 있는 값은 빼고 0을 포함한 나머지는 원래 순서대로 남기는 과정을 보여 줍니다. 원래 배열은 바뀌지 않고 [1, 2, 3, 0, 4]라는 새 배열이 만들어지며, 그림은 pluck과 merge의 결과도 각자의 타입과 짝지어 보여 줍니다. 그림의 마지막 설명은 본문의 pluck과 compact가 가변 배열 매개변수를 쓴다고 적었지만, 이 글의 compact는 readonly 배열을 받고, 읽기 전용 배열에서 값을 모으는 일은 앞의 pluckReadonly가 맡습니다.
입력에 따라 반환 타입이 바뀌는 processValue
문자열을 넣으면 길이를, 숫자를 넣으면 문자열을, 그 밖의 값은 그대로 돌려주는 함수의 반환 타입은 조건부 타입으로 적을 수 있습니다. 하지만 컴파일러는 구현 안의 각 return이 그 조건부 타입과 맞는지 따지지 못하므로, 다음 구현은 as any로 검사를 건너뜁니다.
type Processed<T> = T extends string ? number : T extends number ? string : T;
function processValue<T>(input: T): Processed<T> {
if (typeof input === "string") {
return input.length as any;
} else if (typeof input === "number") {
return String(input) as any;
}
return input as any;
}
const len = processValue("hello");
const text = processValue(123);
const flag = processValue(true);
console.log(len, text, flag);5 123 truelen은 number, text는 string이고, flag는 리터럴을 그대로 넘겼으므로 T가 true로 추론되어 타입도 true입니다. 출력은 구현과 타입이 맞는다는 것을 보여 주지만, 이것은 실행해서 대조했기 때문에 알 수 있는 사실입니다. as any는 각 분기의 반환값이 약속과 맞는지 검사하지 않으므로, 예를 들어 숫자 분기에서 실수로 input * 2를 돌려주어도 컴파일러는 그대로 통과시킵니다.
단언을 쓰지 않으려면 3장의 오버로드로 입력마다 반환 타입을 나누어 적는 방법도 있습니다.
function processValueSafe(input: string): number;
function processValueSafe(input: number): string;
function processValueSafe<T>(input: T): T;
function processValueSafe(input: unknown): unknown {
if (typeof input === "string") return input.length;
if (typeof input === "number") return String(input);
return input;
}
console.log(processValueSafe("hello"), processValueSafe(123));5 123호출한 쪽은 첫 결과를 number, 둘째 결과를 string으로 받으므로 쓰는 쪽의 타입은 같습니다. 다만 3장에서 본 것처럼 오버로드도 구현이 각 시그니처의 반환 타입을 정말 지키는지는 검사하지 않으므로, 어느 방법이든 분기와 반환값이 약속과 맞는지는 테스트로 확인해야 합니다. 타입 술어를 쓰는 규칙은 공식 Handbook의 타입 술어에 있습니다.
더 깊이: 조건부 반환 타입의 검사가 미뤄진 사정
processValue의 구현에서 단언이 필요한 것은 반환 타입이 아직 정해지지 않은 T에 기대는 조건부 타입이기 때문입니다. 본문의 typeof 검사는 값 input을 좁히지만, 컴파일러는 그 결과로 반환 타입의 T까지 좁혀 조건부 타입을 계산하지는 않습니다. 타입스크립트 팀도 TypeScript 5.8 베타 발표에서, 이런 고차 조건부 타입을 돌려주는 함수를 구현하려면 타입 단언이 필요했고 그래서 분기를 바꿔 쓴 실수를 놓쳤다고 설명했습니다.
5.8 베타는 조건부 타입에 쓰인 제네릭 매개변수를 제어 흐름 분석으로 좁히고, 좁힌 타입으로 조건부 타입을 계산해 각 return을 검사하는 기능을 넣었습니다. 이 기능은 매개변수 하나가 그 타입과 이어져 있고, 조건부 타입이 never로 끝나는 분기를 갖추는 등 조건이 까다로웠습니다. 그리고 5.8 정식 발표에서 이 작업은 되돌려져 5.9를 목표로 미뤄졌으며, 5.9와 6.0의 릴리스 노트에도 실리지 않았으므로 이 교재가 기준으로 삼는 6.0에서는 단언이나 오버로드가 여전히 필요합니다. 대신 5.8에는 return 문 바로 안에 쓴 삼항 연산자의 각 분기를 선언한 반환 타입과 따로 비교하는 검사가 남아, 한 분기가 any여서 다른 분기의 잘못된 타입이 가려지던 경우를 잡아 줍니다.
정리
pluck<T, K extends keyof T>처럼 요소 타입과 키를 타입 매개변수로 받으면, 결과의 타입을T[K][]로 정확히 적을 수 있습니다.- 배열을 읽기만 하는 함수는 매개변수를
readonly T[]로 적어야 읽기 전용 배열도 받을 수 있습니다. T & U로 적은 병합 결과는 키가 겹치지 않을 때만 실제 값과 맞습니다.filter의 타입 술어는 콜백이 정말 그 조건을 검사할 때만 맞는 약속이며,null과undefined를 빼는 것과 falsy 값을 빼는 것은 다른 연산입니다.- 조건부 반환 타입을
as any로 맞추면 컴파일러가 반환값을 검사하지 못하므로, 오버로드를 쓰더라도 분기와 반환값은 테스트로 확인합니다.
연습 문제
1. 새 입력에 적용하기
pluckReadonly(readonlyProducts, "id")의 타입과 값, compact([0, null, 7, undefined]), processValue("타입"), processValue(0)의 결과를 각각 적어 보세요.
해설 보기
pluckReadonly(readonlyProducts, "id")는 string[]이고 값은 [ 'P003', 'P004' ]입니다. compact는 두 개의 빈 값만 빼서 [ 0, 7 ]을 돌려줍니다. processValue("타입")은 문자열의 길이인 숫자 2를, processValue(0)은 숫자를 문자열로 바꾼 "0"을 돌려줍니다.
2. 거짓 값을 빼는 함수
compact와 달리 0과 false, 빈 문자열까지 모두 빼는 함수가 필요합니다. possiblyEmpty에 쓰면 무엇이 남으며, 그 결과 배열의 요소 타입은 무엇이어야 하나요?
해설 보기
'완료'만 남습니다. 그런데 falsy 값을 뺀다고 해서 타입에서 number나 string이 사라지는 것은 아니므로, 결과의 요소 타입은 여전히 string | number | true처럼 남은 값이 가질 수 있는 타입이어야 합니다. falsy 값을 뺀 결과를 NonNullable<T>[]로 적으면 0이 빠졌다는 사실은 타입에 드러나지 않으므로, 두 함수는 이름과 동작을 분명히 나누어 둡니다.
3. 단언이 숨긴 실수
processValue의 숫자 분기를 return (input * 2) as any;로 잘못 바꾸었습니다. 검사는 통과하나요? const text = processValue(123);의 타입과 실제 값은 무엇인가요?
해설 보기
검사는 통과합니다. as any가 반환값을 검사하지 않기 때문입니다. text의 타입은 여전히 string이지만 실제 값은 숫자 246이어서, 이어서 text.toUpperCase()를 부르면 검사는 통과하고 실행 중에 text.toUpperCase is not a function으로 멈춥니다. 단언을 쓴 함수일수록 분기마다 실제 반환값을 확인하는 테스트가 필요합니다.
이것으로 6장의 제네릭 심화를 마칩니다. 다음 장의 ES 모듈 시스템에서는 코드를 여러 파일로 나누어 내보내고 가져오는 모듈과, 타입스크립트가 그 모듈을 어떻게 해석하는지 다룹니다.