[TS] 제네릭 (Generics)



01


제네릭이란?

제네릭은 간단하게 "함수나 클래스, 객체 등이 다양한 타입을 처리할 수 있도록 도와주는 도구"라고 할 수 있습니다.

 

예를 들어, 우리가 숫자나 문자를 다루는 함수가 필요할 때, 각기 다른 타입을 처리하려면, 그때마다 함수를 따로 만들어야 할 수도 있죠. 그런데 제네릭을 사용하면, 한 번만 작성한 함수로 다양한 타입을 처리할 수 있게 됩니다.

 

1. 제네릭이 필요한 이유

예를 들어, 우리가 받은 값에 대해 그 값을 그대로 반환하는 함수가 있다고 생각해 볼게요.

처음에는 이런 방식으로 작성할 수 있죠:

function func(value) {
  return value;
}

이 함수는 어떤 타입이든 받을 수 있지만, 타입스크립트에서 any나 unknown을 사용하게 되면 그 타입이 추론되지 않아서 문제가 생길 수 있어요. 예를 들어, 숫자와 문자가 섞여 있는 데이터를 처리할 때, any 타입은 문제가 될 수 있죠.

 

2. 제네릭 함수 만들기

그럼 이제 제네릭을 사용하면 이런 문제를 해결할 수 있어요.

제네릭을 사용해서 타입을 유연하게 처리할 수 있는 함수를 만들 수 있거든요. 예를 들어:

function func<T>(value: T): T {
  return value;
}

여기서 T는 타입 변수입니다. 우리가 어떤 값을 넣을지 모르기 때문에,

T라는 변수를 사용해 두고, 실제로 함수를 사용할 때 T가 어떤 타입인지 자동으로 결정되도록 해줍니다.

(제네릭함수는 함수가 호출될 때 인수에 따라 타입이 정해집니다.)

let num = func(10); // Number 타입
let str = func("hello"); // String 타입

위처럼 func 함수는 T가 숫자일 수도 있고 문자열일 수도 있는 범용적인 함수로 바뀌었습니다.

func(10)을 호출하면 T는 숫자 타입으로, func("hello")를 호출하면 T는 문자열 타입으로 자동으로 설정됩니다.

 

 

제네릭 함수를 호출할 때 다음과 같이 타입 변수에 할당할 타입을 직접 명시하는 것도 가능합니다.

function func<T>(value: T): T {
  return value;
}

let arr = func<[number, number, number]>([1, 2, 3]);


02


타입 변수 응용하기

1. 2개의 타입 변수가 필요한 상황

가끔 두 개의 타입을 다뤄야 하는 경우가 있습니다.

예를 들어, 두 개의 값을 받아서 그 순서를 바꾸는 함수(swap)가 필요할 때, 두 개의 타입 변수를 사용할 수 있습니다.

function swap<T, U>(a: T, b: U): [U, T] {
  return [b, a];
}

const [a, b] = swap("hello", 42);
console.log(a); // number
console.log(b); // string
  • T는 첫 번째 값의 타입, U는 두 번째 값의 타입입니다.
  • swap 함수는 두 값을 바꾸고, a와 b는 각각 number와 string 타입으로 추론됩니다.

2. 다양한 배열 타입을 인수로 받는 제네릭 함수 만들기

다양한 타입의 배열을 인수로 받아 처리하는 함수도 쉽게 만들 수 있습니다.

예를 들어, 배열에서 첫 번째 값을 반환하는 함수는 다음과 같이 제네릭을 사용할 수 있습니다.

function returnFirstValue<T>(data: T[]): T {
  return data[0];
}

let num = returnFirstValue([1, 2, 3]); // number 타입
let str = returnFirstValue(["apple", "banana"]); // string 타입
  • T []는 배열의 타입을 의미합니다. T는 배열의 요소 타입으로 추론됩니다.
  • returnFirstValue 함수는 배열의 첫 번째 요소를 반환하며, 그 타입은 T입니다. 
    배열이 number []이면 반환값도 number 타입이고, string []이면 반환값도 string 타입입니다.

 

3. 배열의 첫 번째 요소의 타입을 반환값으로 사용하는 상황

배열의 첫 번째 요소의 타입을 반환값으로 사용하는 경우는, 특히 배열을 다루는 함수에서 유용합니다.

튜플 타입을 사용하면 첫 번째 요소의 타입을 기반으로 반환값의 타입을 결정할 수 있습니다.

function returnFirstValue<T>(data: [T, ...unknown[]]): T {
  return data[0];
}

let num = returnFirstValue([1, 2, 3]); // number 타입
let str = returnFirstValue(["apple", "banana"]); // string 타입
let mixed = returnFirstValue([1, "hello", true]); // number 타입
  • [T,... unknown []]는 튜플입니다.
    ✅  T: 첫 번째 요소의 타입을 나타냅니다.
    ✅... unknown []: 나머지 요소들은 unknown []로 처리합니다.
    ✅ 즉, 첫 번째 요소의 타입만 중요하고, 나머지 값은 어떤 타입이든 상관없습니다.

왜 unknown []를 사용하는가?

 

unknown []는 타입을 유추할 수 없으므로, 배열의 나머지 요소에 대해서는 추가적인 제약을 두지 않기 위함입니다.

첫 번째 요소의 타입만 유의미하고, 그 이후에 올 수 있는 다양한 타입들은 자유롭게 다룰 수 있도록 합니다.

 

4. 타입 변수를 제한하는 사례

타입 변수를 제한하는 것은 함수가 처리할 수 있는 타입을 제한하는 것입니다.
예를 들어, 길이를 가지는 객체만 허용하고 싶을 때, extends 키워드를 사용하여 제한을 걸 수 있습니다.

function getLength<T extends { length: number }>(data: T): number {
  return data.length;
}

console.log(getLength("hello")); // 5
console.log(getLength([1, 2, 3])); // 3
console.log(getLength({ length: 10 })); // 10
// console.log(getLength(undefined)); // 오류
// console.log(getLength(null)); // 오류
  • T extends { length: number }는 length 프로퍼티가 있는 객체만 허용하도록 제한하는 부분입니다.
  • 문자열이나 배열 같은 length 프로퍼티를 가진 객체만 이 함수에 전달할 수 있습니다.
  • undefined나 null은 length 프로퍼티가 없기 때문에 오류가 발생합니다.


03


제네릭을 사용한 map과 forEach 메서드

자바스크립트의 map과 forEach 같은 메서드를 제네릭으로 정의할 수 있습니다.

map은 각 요소에 대해 어떤 작업을 수행하고 새로운 배열을 만들어 반환하죠.

function map<T, U>(arr: T[], callback: (item: T) => U): U[] {
  let result: U[] = [];
  for (let i = 0; i < arr.length; i++) {
    result.push(callback(arr[i]));
  }
  return result;
}

이 함수는 원본 배열의 타입새로운 배열의 타입을 다르게 할 수 있어요.

예를 들어, 숫자 배열을 받아서 각 요소를 문자열로 변환하는 경우도 가능합니다.

let newArr = map([1, 2, 3], (it) => it.toString()); // string[] 타입


04


제네릭 인터페이스

1. 제네릭 인터페이스 (Generic Interface)

제네릭 인터페이스는 제네릭 함수와 비슷하게, 타입을 동적으로 설정할 수 있습니다.

다만, 인터페이스를 사용할 때는 변수를 정의할 때 반드시 꺾쇠괄호(<>)를 열고, 타입을 직접 할당해야 합니다.

interface KeyPair<K, V> {
  key: K;
  value: V;
}

let keyPair: KeyPair<string, number> = {
  key: "key",
  value: 0,
};
  • KeyPair <K, V>는 제네릭 타입을 두 개 받는 인터페이스입니다. K는 key의 타입을, V는 value의 타입을 나타냅니다.
  • 변수를 선언할 때, 타입을 <string, number>처럼 직접 명시해야 합니다.

2. 제네릭 인덱스 시그니처 (Generic Index Signature)

객체의 동적 키를 제네릭으로 처리할 수 있게 해 줍니다.

즉, 객체의 키를 문자열로 지정하고, 그에 맞는 값을 제네릭 타입으로 설정할 수 있습니다.

interface Map<V> {
  [key: string]: V;
}

let stringMap: Map<string> = {
  key: "value",
};

let booleanMap: Map<boolean> = {
  key: true,
};

 

3. 제네릭 타입 별칭 (Generic Type Alias)

제네릭 타입 별칭은 인터페이스와 비슷하지만 문법적으로 타입 별칭을 사용하여 타입을 정의합니다.

제네릭 타입 별칭을 사용하면, 제네릭 인터페이스와 동일한 방식으로 다양한 타입을 처리할 수 있습니다.

type Map2<V> = {
  [key: string]: V;
};

let stringMap2: Map2<string> = {
  key: "string",
};
  • Map2 <V>는 key가 string이고, value가 제네릭 타입 V인 객체 타입입니다.
  • 이 타입을 Map2 <string>으로 선언하면 value가 string 타입인 객체로 설정됩니다.


05


제네릭 인터페이스 활용 예시

여기서는 학생개발자를 구분하여 처리하는 예시를 제시합니다.

이 예시는 제네릭 인터페이스를 사용하여 코드가 더 간결하고 유연하게 동작하도록 만듭니다.

 

✅ 학생과 개발자 타입 정의

먼저, 학생개발자를 각각 타입으로 정의합니다.

interface Student {
  type: "student";
  school: string;
}

interface Developer {
  type: "developer";
  skill: string;
}
  • Student는 type이 "student"이고, school 속성이 있습니다.
  • Developer는 type이 "developer"이고, skill 속성이 있습니다.

✅ User 인터페이스

User 인터페이스는 name과 profile을 가지고 있으며, profile의 타입을 Student나 Developer로 정의할 수 있습니다.

interface User {
  name: string;
  profile: Student | Developer;
}
  • profile은 학생 또는 개발자 타입을 가질 수 있습니다.

구체적인 사용자 객체 예시

const developerUser: User = {
  name: "아무개",
  profile: {
    type: "developer",
    skill: "typescript",
  },
};

const studentUser: User = {
  name: "홍길동",
  profile: {
    type: "student",
    school: "한국대학교",
  },
};
  •  developerUser는 개발자로, studentUser는 학생으로 설정되었습니다.

✅ 학생만 이용할 수 있는 함수 (goToSchool)

학생만 사용할 수 있는 함수 goToSchool을 만들고,

user.profile이 "student"일 때만 실행되도록 제한하려면 타입을 좁히는 조건문을 작성해야 합니다.

function goToSchool(user: User) {
  if (user.profile.type !== "student") {
    console.log("잘못 오셨습니다");
    return;
  }

  const school = user.profile.school;
  console.log(`${school}로 등교 완료`);
}
  • 여기서는 user.profile.type이 "student"가 아니라면 오류 메시지를 출력합니다.

제네릭을 사용한 개선

매번 조건문으로 type을 좁히는 대신 제네릭 인터페이스를 사용하여 User 타입을 구체적으로 지정할 수 있습니다.

이를 통해 코드가 더 간결해지죠.

interface User<T> {
  name: string;
  profile: T;
}

function goToSchool(user: User<Student>) {
  const school = user.profile.school;
  console.log(`${school}로 등교 완료`);
}
  • goToSchool 함수는 User <Student> 타입으로만 호출할 수 있으므로, 매번 타입을 좁힐 필요가 없어집니다.

구체적인 객체 예시 (제네릭 적용)

const studentUser: User<Student> = {
  name: "홍길동",
  profile: {
    type: "student",
    school: "한국대학교",
  },
};

goToSchool(studentUser);  // 정상 작동
  • goToSchool 함수는 이제 학생만 받을 수 있습니다.


06


제네릭 클래스 (Generic Class)

제네릭 클래스는 다양한 타입을 다룰 수 있는 클래스입니다.

타입을 동적으로 지정할 수 있기 때문에, 같은 클래스를 여러 타입에 대해 재사용할 수 있어 유용합니다.

 

📌 언제 쓰면 좋을까요?

: 같은 작업을 여러 타입에 대해 반복하려면 제네릭 클래스를 사용하면 코드가 깔끔해집니다.

 

예시 1: 여러 타입을 다루는 박스 클래스

타입을 동적으로 지정하여 하나의 클래스로 다양한 타입을 처리할 수 있습니다.

class Box<T> {
  value: T;

  constructor(value: T) {
    this.value = value;
  }

  getValue(): T {
    return this.value;
  }
}

// 문자열과 숫자 타입을 다룰 수 있음
const stringBox = new Box("Hello");
const numberBox = new Box(123);

 

예시 2: 타입 제한이 있는 컨테이너 클래스

타입 제한을 두어, 오직 string 타입만 사용할 수 있도록 할 수 있습니다.

class Container<T extends string> {
  value: T;

  constructor(value: T) {
    this.value = value;
  }
}

const stringContainer = new Container("Hello");
// const numberContainer = new Container(123); // 오류 발생!


07


프로미스와 제네릭

프로미스(Promise)비동기 작업을 처리할 때, 그 작업이 끝나면 결괏값이나 에러를 반환하는 객체입니다.

예를 들어, 비동기 작업(서버에서 데이터 받아오기)이 끝나면 그 결과를 프로미스가 알려주는 역할을 합니다.

 

📌프로미스(Promise)를 까먹었다면 아래의 포스팅을 봐주세요.

 

[JS] 프로미스 (Promise)

01Promise자바스크립트에서 비동기 작업이 끝날 때, 그 결과를 알려주는 "약속"과 같은 역할의 오브젝트입니다.예를 들어, 어떤 일이 잘 처리되면 결과를 알려주고, 문제가 생기면 에러를 알려주는

dev-watnu.tistory.com

 

하지만 프로미스는 결괏값의 타입을 자동으로 추론하지 못하기 때문에,

이를 제네릭을 사용하여 명시적으로 지정할 수 있습니다.

 

✅ 기본 프로미스 사용

const promise = new Promise<number>((resolve, reject) => {
  setTimeout(() => {
    resolve(20); // 결과값: 20 (number 타입)
  }, 3000);
});

promise.then((response) => {
  console.log(response); // response는 number 타입
});
  • Promise <number>는 비동기 작업의 결과가 number 타입임을 명시합니다.
  • then에서 response는 자동으로 number 타입으로 추론됩니다.

하지만 resolve를 사용할 때, 반환 타입을 추론하지 못해 오류가 생길 수 있습니다.

제네릭을 사용하여 이를 방지할 수 있습니다.

 

에러 처리와 제네릭

const promiseWithError = new Promise<number>((resolve, reject) => {
  setTimeout(() => {
    reject("Something went wrong");
  }, 3000);
});

promiseWithError
  .then((response) => {
    console.log(response); // response는 number 타입
  })
  .catch((error) => {
    if (typeof error === "string") {
      console.log(error); // error는 string 타입
    }
  });
  • catch: 에러가 발생하면 error의 타입을 확인할 수 있습니다. 이 경우, error는 string 타입임을 명시적으로 체크합니다.


08


마무리

✅ 제네릭 간단 개념 요약

제네릭은 함수나 클래스, 객체가 다양한 타입을 처리할 수 있도록 도와주는 타입 변수입니다.

  • 유연성: 같은 코드로 여러 타입을 처리할 수 있게 해 줍니다.
  • 타입 안전성: 타입 오류를 줄이고, 타입을 명확히 지정하여 안전한 코드 작성이 가능합니다.
  • 재사용성: 여러 타입에 대해 동일한 함수나 클래스를 재사용할 수 있습니다.

즉, 제네릭은 타입을 동적으로 처리하면서 코드의 유연성과 안전성을 높여주는 개념입니다.

 

 

 


이미지출처: 한 입 크기로 잘라먹는 타입스크립트(TypeScript)

 

 

'DEVELOPMENT > Typescript' 카테고리의 다른 글

[TS] 조건부 타입 (Conditional Types)  (0) 2025.03.05
[TS] 타입 조작하기  (0) 2025.03.04
[TS] 클래스 (class)  (1) 2025.03.01
[TS] 인터페이스 (Interface)  (0) 2025.02.28
[TS] 함수와 타입  (1) 2025.02.27