iBetter Books
수정

제네릭 함수 만들기

요리사가 어떤 재료든 받아서 같은 방식으로 조리하는 기계를 만든다고 합시다. 기계는 재료가 뭐든 상관없이 작동합니다. 하지만 소고기를 넣으면 소고기 요리가 나오고, 연어를 넣으면 연어 요리가 나옵니다. 들어간 재료의 "종류 정보"가 끝까지 유지됩니다.

제네릭 함수가 이 기계입니다. <T>가 "어떤 재료든"이라는 선언이고, 입력과 출력이 같은 T로 연결되어 있어서 타입 정보가 유지됩니다.

가장 단순한 제네릭 함수 — identity

제네릭을 설명할 때 전통적으로 쓰는 예제가 있습니다. 받은 값을 그대로 돌려주는 identity 함수입니다.

// 새 파일: identity.tsfunction identity<T>(value: T): T {  return value;}const num = identity(42);// T는 number로 추론됩니다// num의 타입은 numberconst str = identity("hello");// T는 string으로 추론됩니다// str의 타입은 stringconst arr = identity([1, 2, 3]);// T는 number[]로 추론됩니다// arr의 타입은 number[]console.log(num.toFixed(2));    // 42.00console.log(str.toUpperCase()); // HELLOconsole.log(arr.length);        // 3

<T>는 함수 이름과 매개변수 목록 사이에 씁니다. 이 T는 매개변수 타입에도 쓰이고(value: T) 반환 타입에도 쓰입니다(: T). 이렇게 연결되어 있기 때문에 TypeScript가 T가 뭔지 알면 나머지도 자동으로 결정됩니다.

타입 추론 — TypeScript가 알아서 한다

앞의 예제에서 identity(42)를 호출할 때 T를 명시하지 않았습니다. TypeScript가 42를 보고 스스로 T = number라고 추론했습니다. 이것이 타입 추론입니다.

// 새 파일: type-inference.tsfunction wrap<T>(value: T): { data: T } {  return { data: value };}const result1 = wrap(100);// T = number 로 추론// result1의 타입은 { data: number }console.log(result1.data.toFixed(2));   // 100.00const result2 = wrap("TypeScript");// T = string 으로 추론// result2의 타입은 { data: string }console.log(result2.data.toUpperCase()); // TYPESCRIPTconst result3 = wrap({ name: "Alice", age: 30 });// T = { name: string; age: number } 로 추론// result3의 타입은 { data: { name: string; age: number } }console.log(result3.data.name); // Alice

TypeScript는 전달된 인수를 보고 T를 결정합니다. 대부분의 경우 추론이 잘 됩니다.

타입 명시 — 직접 지정할 수도 있다

추론이 안 될 때나, 의도를 명확히 하고 싶을 때는 직접 지정할 수 있습니다. 함수 이름 뒤 꺾쇠괄호 안에 씁니다.

// 새 파일: explicit-type.tsfunction getFirst<T>(arr: T[]): T | undefined {  return arr[0];}// 추론 — TypeScript가 number[]를 보고 T = numberconst a = getFirst([1, 2, 3]);// 명시 — T를 직접 string으로 지정const b = getFirst<string>(["a", "b", "c"]);// 빈 배열은 추론이 안 됩니다 — never[]로 추론됩니다const c = getFirst([]);        // c의 타입은 never | undefinedconst d = getFirst<number>([]); // d의 타입은 number | undefined

빈 배열처럼 TypeScript가 원소 타입을 알 수 없을 때는 명시적으로 지정해야 합니다. getFirst<number>([]) 처럼요.

여러 타입 매개변수

매개변수가 여러 개인 함수처럼, 타입 매개변수도 여러 개를 쓸 수 있습니다.

// 새 파일: multiple-type-params.tsfunction pair<T, U>(first: T, second: U): [T, U] {  return [first, second];}const p1 = pair(1, "hello");// T = number, U = string// p1의 타입은 [number, string]console.log(p1[0].toFixed(1));    // 1.0console.log(p1[1].toUpperCase()); // HELLOconst p2 = pair("Alice", 30);// T = string, U = number// p2의 타입은 [string, number]console.log(p2[0].toUpperCase()); // ALICEconsole.log(p2[1].toFixed(0));    // 30const p3 = pair(true, [1, 2, 3]);// T = boolean, U = number[]// p3의 타입은 [boolean, number[]]

TU는 서로 독립적인 타입 매개변수입니다. 각각 다른 타입으로 추론됩니다.

배열을 다루는 유틸 함수

실제로 자주 쓰이는 패턴을 만들어봅시다.

// 새 파일: array-utils.ts// 배열에서 첫 번째 요소 반환function first<T>(arr: T[]): T | undefined {  return arr.length > 0 ? arr[0] : undefined;}// 배열에서 마지막 요소 반환function last<T>(arr: T[]): T | undefined {  return arr.length > 0 ? arr[arr.length - 1] : undefined;}// 배열에서 특정 인덱스 요소 반환function getAt<T>(arr: T[], index: number): T | undefined {  return arr[index];}// 두 배열 합치기function concat<T>(arr1: T[], arr2: T[]): T[] {  return [...arr1, ...arr2];}// 사용 예시const numbers = [10, 20, 30, 40, 50];console.log(first(numbers));         // 10console.log(last(numbers));          // 50console.log(getAt(numbers, 2));      // 30const names = ["Alice", "Bob", "Carol"];const moreNames = ["Dave", "Eve"];const allNames = concat(names, moreNames);console.log(allNames); // ["Alice", "Bob", "Carol", "Dave", "Eve"]// 반환 타입이 정확합니다const firstNum = first(numbers);      // number | undefinedconst firstStr = first(names);        // string | undefined

제네릭 덕분에 first(numbers)number | undefined를 반환하고, first(names)string | undefined를 반환합니다. 함수 하나로 모든 타입을 처리하면서도 타입 정보가 살아있습니다.

함수 표현식과 화살표 함수

제네릭은 화살표 함수에도 쓸 수 있습니다.

// 새 파일: generic-arrow.ts// 함수 선언식function identityFn<T>(value: T): T {  return value;}// 함수 표현식const identityExpr = function <T>(value: T): T {  return value;};// 화살표 함수 (tsx 파일이 아닌 경우)const identityArrow = <T>(value: T): T => {  return value;};// 모두 동일하게 동작합니다console.log(identityFn(42));      // 42console.log(identityExpr(42));    // 42console.log(identityArrow(42));   // 42

.tsx 파일(React JSX)에서는 <T> 문법이 JSX 태그와 혼동될 수 있습니다. 그럴 때는 <T,> 처럼 쉼표를 추가하거나 <T extends unknown> 처럼 씁니다. 일반 .ts 파일에서는 그냥 <T>를 쓰면 됩니다.

반환 타입을 명시하는 경우

대부분의 경우 반환 타입은 TypeScript가 추론합니다. 하지만 복잡한 함수에서는 명시하는 것이 가독성에 좋습니다.

// 새 파일: return-type.ts// 추론에 맡기기function pickRandom<T>(arr: T[]) {  const index = Math.floor(Math.random() * arr.length);  return arr[index];  // TypeScript가 T를 반환한다고 추론}// 명시하기 — 의도가 더 명확합니다function pickRandom2<T>(arr: T[]): T {  const index = Math.floor(Math.random() * arr.length);  return arr[index];}const randomNum = pickRandom([1, 2, 3, 4, 5]);// randomNum의 타입은 numberconst randomStr = pickRandom(["apple", "banana", "cherry"]);// randomStr의 타입은 stringconsole.log(randomNum.toFixed(0));    // 1~5 중 하나console.log(randomStr.toUpperCase()); // APPLE, BANANA, CHERRY 중 하나

제네릭이 아닌 경우와 비교

제네릭이 꼭 필요한 상황과 그렇지 않은 상황을 구분해봅시다.

// 새 파일: when-to-use-generic.ts// 제네릭이 필요 없는 경우 — 타입이 고정되어 있습니다function addNumbers(a: number, b: number): number {  return a + b;}// 제네릭이 필요한 경우 — 타입이 호출 시점에 결정됩니다function identity<T>(value: T): T {  return value;}// 제네릭처럼 보이지만 필요 없는 경우 — 반환 타입이 매개변수와 무관합니다// 이 함수는 어떤 값이든 받아서 항상 string을 반환합니다function stringify(value: unknown): string {  return String(value);}// 타입 매개변수가 한 곳에만 쓰이는 경우 — 의미가 없습니다// 아래 함수에서 T는 아무 역할도 하지 않습니다 (이렇게 쓰지 마세요)function print<T>(value: T): void {  console.log(value);}// 이 경우는 그냥 unknown을 쓰는 게 낫습니다function print2(value: unknown): void {  console.log(value);}

제네릭은 "입력 타입과 출력 타입 사이에 관계가 있을 때" 사용합니다. 단순히 "어떤 타입이든 받는다"는 이유만으로 쓰면 오히려 코드가 복잡해집니다.


요리 기계에 재료를 넣으면 같은 재료가 처리되어 나옵니다. <T>라는 라벨 덕분에 소고기를 넣으면 소고기 요리가, 연어를 넣으면 연어 요리가 나옵니다. 재료의 종류가 처음부터 끝까지 추적됩니다.

제네릭 함수를 만드는 기본 문법을 익혔습니다. 대부분의 상황에서 TypeScript가 타입을 자동으로 추론해주지만, 빈 배열처럼 추론이 안 될 때는 직접 명시하면 됩니다.

다음 장에서는 제네릭에 조건을 달아봅니다. "어떤 타입이든"이 아니라 "이 조건을 갖춘 타입만"으로 범위를 제한하는 방법을 배웁니다.