제네릭 (Generics)

원정·2025년 5월 30일
9

TypeScript

목록 보기
1/1
post-thumbnail

한 입 크기로 잘라먹는 타입스크립트를 보고 정리한 내용입니다.

1. 제네릭


function func(value: any) {
  return value;
}

let num = func(10);
let bool = func(true);
let str = func("string");

위와 같은 코드를 작성하면 num, bool, str의 타입은 모두 any입니다.
함수는 리턴값을 기준으로 타입을 추론하기 때문입니다.

num.toUpperCase();

하지만 num 이 any 타입으로 추론되기 때문에 String 메서드인 toUpperCase를 사용해도 오류를 발생시키지 않습니다.

function func(value: unknown) {
  return value;
}

let num = func(10);
num.toUpperCase(); // 오류!
num.toFixed(); // 오류!

unknown 타입으로 정의하면 toUpperCase 메서드 사용 시 오류를 발생 시킬 수 있지만, toFixed와 같은 Number 메서드도 오류를 발생 시키는 문제가 있습니다.

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

이때 func 함수를 제네릭 함수로 만들어 인자에 따라 반환 타입을 가변적으로 정해줄 수 있습니다.
T는 타입을 저장하는 타입 변수입니다.
타입 변수는 인자로 어떤 타입을 전달하는지에 따라 저장되는 타입이 달라지며, 타입 파라미터, 제네릭 타입 변수, 제네릭 타입 파라미터 등으로도 불립니다.

let num = func(10);
let bool = func(true);
let str = func("string");

제네릭 함수로 변환하면 num, bool, str의 타입은 number, boolean, string으로 정의됩니다.
타입 변수 T는 자바스크립트의 변수처럼 상황에 따라 다른 타입을 담을 수 있습니다.
타입스크립트는 함수 호출 시 전달된 인자의 타입을 기준으로 타입 변수의 타입을 추론합니다.

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

number[] 타입을 갖는 값을 인자로 전달하면 타입 변수에 number[]이 담겨 arr의 타입은 number[]가 되는데요.
이때 arr의 타입을 tuple로 정의하고 싶다면 어떻게 해야 할까요?

// 타입 단언
let arr = func([1, 2, 3] as [number, number, number]);
// 또는 직접 작성
let arr = func<[number, number, number]>([1, 2, 3]);

타입 단언을 사용해도 되지만 함수 호출할 때 타입 변수 T에 할당해주고 싶은 타입을 직접 작성하여 해결할 수 있습니다.

2. 타입 변수 응용하기


2.1. 같은 타입일 수도 있고 다른 타입일 수도 있는 두 개의 인자를 받는 함수

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

const [a, b] = swap("1", 2); // 오류!

오류가 발생하는 이유는 첫 번째 인자로 string 타입의 값을 전달하면 타입 변수에 string 타입이 할당되면서 두 번째 인자도 string 타입이 할당되기 때문입니다.

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

이 문제는 타입 변수를 하나 더 선언해줌으로써 해결할 수 있습니다.
타입 변수는 하나만 선언할 필요없이, 여러 개 선언할 수 있습니다.

2.2. 인자로 받은 배열의 첫 번째 값을 반환하는 함수

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

let num = returnFirstValue([0, 1, 2]);
let str = returnFirstValue(["hello", "mynameis"]);

위 코드에서 data: T[]이 아닌 data: T로 작성하면 오류가 발생합니다.

함수 내부에서는 호출되어 타입 변수의 타입이 결정되기 전에 최대한 오류를 발생시키지 않기 위해 unknown으로 추론합니다.
따라서 unknown 타입의 값에 배열 인덱스를 사용했기 때문에 오류가 발생합니다.

data: T[]로 선언해주면 어떤 배열이 오든 인덱스 접근이 가능하기 때문에 오류가 사라지고 numstrnumberstring 타입으로 추론합니다.

let str = returnFristValue([1, "hello", "mynameis"]);

만약 위와 같이 작성하게 된다면 number | string으로 추론하는데요.

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

만약 배열의 첫 번째 인덱스 타입만을 할당받고 싶다면 인자의 타입을 [T, …unknown[]]로 바꿔 받아 해결할 수 있습니다.

2.3. 특정 속성을 갖고 있는 인자만 받고 싶은 함수

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

만약 length와 같이 특정 속성이 있는 값만 인자로 받는 함수를 만들고 싶다면 extends을 이용할 수 있습니다.

3. Array.property.map() 타입 정의하기


const arr = [1, 2, 3];
const newArr = arr.map((it) => it * 2);

위와 같은 상황일 때 map 메서드에서 콜백 함수의 it는 number 타입으로 추론합니다.
인자의 타입을 추론할 수 있는 이유는 map 메서드의 타입이 어딘가에 별도로 선언되어 있기 때문입니다.

lib.es5.d.ts라는 자바스크립트 내장 함수의 타입들이 선언된 파일이 있습니다.
여기서 map 메서드의 타입을 볼 수 있는데요.
꽤 복잡해보이는 map 메서드의 타입을 직접 구현해보도록 하겠습니다.

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

Array.prototype.map()은 이미 선언되어 있기 때문에 별도로 메서드 타입을 정의하기 위해 map 함수를 만들어줍니다.

map(arr, (it) => it * 2);
map("hi", "hello"], (it) => it.toUpperCase());
map(["hi", "hello"], (it) => parseInt(it)); // 오류!

오류가 발생하는 이유는 콜백 함수의 반환 타입이 number가 되기 때문입니다.
하지만 실제 map 메서드는 string 타입의 배열을 인자로 넘겨도 결과가 반드시 string 배열 타입은 아닙니다.

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

map(["hi", "hello"], (it) => parseInt(it));

이럴 때는 타입 변수를 추가해줍니다.
arr에 string[] 타입이 들어오고 item 타입도 string이 됩니다.
콜백 함수의 반환값은 number 타입이기 때문에 U의 타입을 이때 추론하여 number 타입이 들어오게 됩니다.

4. 제네릭 인터페이스와 타입 별칭


4.1. 제네릭 인터페이스

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

let keyPair: KeyPair<string, number> = {
  key: "key",
  value: 0,
};

제네릭 인터페이스를 사용할 때에는 반드시 제네릭 타입 인자를 명시해야 합니다.
제네릭 인터페이스는 특히 객체의 인덱스 시그니처 문법과 함께 사용하면 유연한 객체 타입을 만들 수 있습니다.

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

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

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

인덱스 시그니처와 제네릭 인터페이스를 사용해서 만든 Map 타입은 다양한 객체를 표현할 수 있습니다.

4.2. 제네릭 타입 별칭

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

let stringMap2: Map2<string> = {
  key: "hello",
};

제네릭 타입 별칭을 만드는 방법은 인터페이스와 거의 비슷합니다.

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

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

interface Developer {
  type: "developer";
  skill: string;
}

interface User {
  name: string;
  profile: Student | Developer;
}

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

  const school = user.profile.school;
  console.log(`${school}로 등교 완료`);
}

const developerUser: User = {
  name: "장원정",
  profile: {
    type: "developer",
    skill: "TypeScript",
  },
}

const studentUser: User = {
  name: "홍길동",
  profile: {
    type: "student",
    school: "신성중학교",
  },
}

goToSchool 함수는 User 타입을 인자로 받아서 profile.type이 student일 때만 등교 완료를 출력하는 함수입니다.
User의 구분이 많아지고 특정 회원만 이용할 수 있는 함수가 많아지면 함수를 만들 때마다 타입 좁히기를 사용해야 하기 때문에 확장성이 좋지 않습니다.
이럴 때 제네릭 인터페이스를 사용하면 깔끔하게 코드를 작성할 수 있습니다.

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

interface Developer {
  type: "developer";
  skill: string;
}

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

function goToSchool(user: User<Student>) {
  const school = user.profile.school;
  console.log(`${school}로 등교 완료!`);
}

const developerUser: User<Developer> = {
  name: "장원정",
  profile: {
    type: "developer",
    skill: "TypeScript",
  },
}

const studentUser: User<Student> = {
  name: "홍길동",
  profile: {
    type: "student",
    school: "신성중학교",
  }
}

goToSchool(developerUser); // 오류!

User 인터페이스를 제네릭 인터페이스로 바꾸고, profile의 타입으로 제네릭 타입 매개 변수를 사용합니다.
이렇게 수정하면 goToSchool 함수에서 타입 좁히기 코드없앨 수 있습니다.
복잡한 객체 타입을 정의하여 사용할 때, 제네릭 인터페이스를 활용하면 코드와 타입 정의를 깔끔하게 분리할 수 있어 유용합니다.

5. 제네릭 클래스


class NumberList {
  constructor(private list: number[]) {}

  push(data: number) {
    this.list.push(data);
  }

  pop() {
    return this.list.pop();
  }

  print() {
    console.log(this.list);
  }
}

const numberList = new NumberList([1, 2, 3]);
numberList.pop();
numberList.push(4);
numberList.print(); // [1, 2, 4]

위 코드에서 추가로 StringList 클래스도 필요한 상황이라면, 어떻게 확장하면 좋을까요?

class List<T> {
  constructor(private list: T[]) {}
 
  push(data: T) {
    this.list.push(data);
  }
 
  pop() {
    return this.list.pop();
  }
 
  print() {
    console.log(this.list);
  }
}
 
const numberList = new List([1, 2, 3]);
numberList.pop();
numberList.push(4);
numberList.print(); // [1, 2, 4]
 
const stringList = new List(["1", "2"]);

기존의 NumberList 클래스는 타입을 모두 number로 고정해놔서 확장성이 좋지 않습니다.
List라는 제네릭 클래스를 제네릭 클래스로 만들면, 생성자 함수에 들어오는 타입을 타입 변수에 할당하여 인자로 전달하는 배열 타입에 대응할 수 있습니다.

제네릭 클래스는 제네릭 인터페이스와 제네릭 타입 별칭과는 다르게 클래스의 생성자를 호출할 때 생성자의 인자로 전달하는 값을 기준으로 타입을 추론하기 때문에 반드시 타입 명시를 해주지 않아도 됩니다.

6. 프로미스와 제네릭


const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(20);
  }, 3000);
});

promise.then((response) => {
  console.log(response); // 20;
});

위 코드에서 console.log(response * 10);으로 수정하면 오류가 발생하는데요.
왜냐하면 response는 unknown 타입으로 추론하기 때문입니다.
Promise는 resolve로 전달되는 값의 타입을 명시하지 않으면 기본적으로 unknown으로 추론합니다.

const promise = new Promise<number>((resolve, reject) => {
  setTimeout(() => {
    resolve(20);
  }, 3000);
});

promise.then((response) => {
  console.log(response * 10);
});

자바스크립트의 내장 클래스인 Promise는 타입스크립트에서 제네릭 클래스로 타입이 선언되어 있습니다.
생성자 함수를 호출할 때 비동기 작업의 결과값 타입을 제네릭 타입 인자로 명시해주면 response의 타입이 해당 타입으로 추론되고 resolve 함수도 반드시 그 타입으로만 받을 수 있게 바뀝니다.

const promise = new Promise<number>((resolve, reject) => {
  setTimeout(() => {
    reject("~~ 때문에 실패");
  }, 3000);
}

promise.catch((err) => {
  if(typeof err === "string") {
    console.log(err);
  }
});

reject 함수는 내부적으로 reject: (reason?:any) => void로 정의되어 있습니다.
catch의 인자 err의 타입도 any로 추론합니다.
따라서 인자의 타입을 정확히 알 수 없기 때문에 프로젝트의 상황에 맞게 타입 좁히기를 사용해야 합니다.

6.1. 프로미스를 반환하는 함수의 타입 정의

interface Post {
  id: number;
  title: string;
  content: string;
}

function fetchPost() {
  return new Promise<Post>((resolve, reject) => {
    setTimeout(() => {
      resolve({
        id: 1,
        title: "게시글 제목",
        content: "게시글 컨텐츠",
      });
    }, 3000);
  });
}

const postRequest = fetchPost();

postRequest.then(post => post.id);

Promise를 반환하는 함수의 타입을 정의할 때는 return문의 Promise 생성자 함수에서 제네릭 타입 인자를 명시하거나,

function fetchPost(): Promise<Post> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        id: 1,
        title: "게시글 제목",
        content: "게시글 컨텐츠",
      });
    }, 3000);
  });
}

fetchPost 함수의 반환 타입으로 Promise<Post>를 작성해줄 수도 있습니다.
후자가 함수의 인터페이스만 봐도 반환 타입을 알 수 있기 때문에 가독성 면에서 더 좋습니다.

profile
https://wonjung-jang.github.io/ 로 이동했습니다!

10개의 댓글

comment-user-thumbnail
2025년 5월 31일

요즘 타입챌린지 할 때 제일 고민해보는 부분이 제네릭인데, 주제 다루어주셔서 반갑습니다.
제네릭을 실제 사용할 때 저는 잘써야 편리하고 가독성이 좋아진다고 생각하는데, 원정님께서는 어떤 상황에 제네릭을 고민하시는지 알고싶습니다!

1개의 답글
comment-user-thumbnail
2025년 5월 31일

제네릭은 사용하기에도 읽기에도 어려운것 같습니다... 🥹 오픈소스에서는 항상 사용되고 있어서 제네릭이 익숙해진다면 오픈소스 코드를 파악하는데 도움이 될 것 같아 익히려고 노력하고있는데 쉽지 않네요... 글 잘 봤습니다 ㅎㅎ!!

1개의 답글
comment-user-thumbnail
2025년 5월 31일

제네릭.. 뭔가 정이 잘 안가서 잘 활용하지 못하는데 작성해주신 글 참고해서 한번 친해져봐야겠습니다..ㅎ_ㅎ

1개의 답글
comment-user-thumbnail
2025년 6월 1일

제네릭은 처음 공부할 때도 어려웠지만, 알겠다 싶다가도 막상 구성하면 헷갈리더라구요.
특히 라이브러리 사용할 때, 구성한 제네릭이 결국 받지 못하게 되어 있어, 열심히 구성하다 롤백한 경험이 떠오르네요 ;ㅅ;
타입스크립트의 첫번째 관문이라고 생각하는데, 공유해주신 내용으로 시작해서 타입스크립트를 더 공부해 봐야겠습니다.
좋은 글 감사합니다!!

1개의 답글
comment-user-thumbnail
2025년 6월 9일

제네릭이 참... 중요한데 특히 프로미스 사용할 일 있을 때 자주 빼먹게 되는 것 같아요 ㅋㅋ큐ㅠㅠㅠ 나중에 cheat sheet로 사용할 수 있겠구나 싶은 좋은 글이었습니다!!!

1개의 답글