TypeScript(타입스크립트) 제네릭

·2026년 1월 26일

type-script

목록 보기
6/9

제네릭이란?

함수나 인터페이스, 타입 별칭, 클래스 등을 다양한 타입과 함께 동작하도록 만들어 주는 타입스크립트의 기능이다.

제네릭이 필요한 상황

다양한 타입의 매개변수를 받고 해당 매개변수를 그대로 반환하는 함수가 하나 필요하다고 가정하면 아래와 같이 할 수 있다.

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

let num = func(10);

let str = func("string");

하지만 any를 사용하는건 위험하므로 다른 타입으로 선언하고 싶다면 unknown으로 선언할 수 있는데, 그렇게 선언하게 되면 아래와 같은 문제가 발생한다.

// unknown으로 선언
function func(value: unknown) {
  return value;
}

let num = func(10);
// unknown 타입

let str = func("string");
// unknown 타입

num.toUpperCase(); // ❌
num.toFixed(); // ❌

따라서 num에 10이 저장될 것이 분명한데도 이 값을 사용하려면 다음과 같이 비효율적으로 타입 좁히기를 이용해야 한다.

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

let num = func(10);
// unknown 타입

let str = func("string");
// unknown 타입

if (typeof num === "number") {
  num.toFixed();
}

그냥 인수로 number 타입의 값을 전달하면 반환 타입이 number가 되고, 인수로 string 타입의 값을 전달하면 반환값의 타입도 string 타입이 되었으면 좋겠는데 지금까지 우리가 배운 타입스크립트 문법으로는 이 문제를 해결할 수 없다.
이럴때 바로 제네릭을 사용할 수 있다.

제네릭(Generic) 함수

제네릭 : 일반적인, 포괄적인

제네릭 함수는 두루두루 모든 타입의 값을 다 적용할 수 있는 범용적인 함수이다.

다음과 같이 제네릭 함수를 선언할 수 있다.

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

let num = func(10);
// number 타입

함수 이름 뒤에 꺽쇠를 열고 타입을 담는 변수인 타입 변수 T를 선언한다. 그리고 매개변수와 반환값의 타입을 이 타입변수 T로 설정한다.

T에 어떤 타입이 할당될 지는 함수가 호출될 때 결정된다.

func(10) 처럼 number 타입의 값을 인수로 전달하면 매개변수 value에 number 타입의 값이 저장되면서 T가 number 타입으로 추론된다. 이때 T가 number 타입으로 추론되고, 이때의 func 함수의 반환값 타입또한 Number 타입이 된다.

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

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

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

위 코드의 흐름은 다음과 같다.

  1. T에 [Number, Number, Number] 튜플 타입이 할당됨
  2. 매개변수 value와 반환값 타입이 모두 튜플 타입이 됨

만약 위 코드에서 타입 변수에 할당할 타입을 튜플 타입으로 설정하지 않았다면 T가 number[] 타입으로 추론 되었을 것이다.

타입스크립트는 타입을 추론할 때 항상 일반적이고 좀 더 범용적인 타입으로 추론하기 때문인데, 이렇듯 타입 변수에 할당하고 싶은 특정 타입(ex. <[number, number, number]>)이 존재한다면 함수 호출과 함께 꺽쇠를 열고 직접 명시해주는게 좋다.

그렇지 않은 대다수의 상황에서는 알아서 잘 추론되기 때문에 굳이 타입 변수를 설정하지 않아도 된다.

타입 변수 응용

2개가 필요한 경우

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

const [a, b] = swap("1", 2);

T는 String 타입으로 U는 Number 타입으로 추론된다.

다양한 배열 타입을 인수로 받는 제네릭 함수

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

let num = returnFirstValue([0, 1, 2]);
// number

let str = returnFirstValue([1, "hello", "mynameis"]);
// number | string

함수 매개변수 data의 타입을 T[]로 설정했기 때문에 배열이 아닌 값은 인수로 전달할 수 없게 된다. 배열을 인수로 전달하면 T는 배열의 요소 타입으로 할당된다.

첫번째 호출에서는 인수로 number[] 타입의 값을 전달했으므로 이때의 T는 number 타입으로 추론된다. 이때의 함수 반환값 타입은 number 타입이 됩니다.

두번째 호출에서는 인수로 (string | number)[] 타입의 값을 전달했으므로 이때의 T는 string | number 타입으로 추론된다. 이때의 함수 반환값 타입은 string | number 타입이 된다. 첫번째 인덱스의 타입을 무조건 가져오는게 아님!

반환값의 타입을 배열의 첫번째 요소의 타입

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

let str = returnFirstValue([1, "hello", "mynameis"]);
// number

함수 매개변수의 타입을 정의할 때 튜플 타입을 이용해 첫번째 요소의 타입은 T 그리고 나머지 요소의 타입은 …unknown[] 으로 길이도 타입도 상관 없도록 정의한다.

함수를 호출하고 [1, “hello”, “mynameis”] 같은 배열 타입의 값을 인수로 전달하면 T는 첫번째 요소의 타입인 number 타입이 되고, 함수 반환값 타입또한 number 타입이 된다.

타입 변수를 제한하는 사례

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

getLength("123");            // ✅

getLength([1, 2, 3]);        // ✅

getLength({ length: 1 });    // ✅

getLength(undefined);        // ❌

getLength(null);             // ❌

타입 변수를 제한할 때에는 확장(extends)을 이용한다.

위와 같이 T extends { length : number } 라고 정의하면 T는 이제 { length : number } 객체 타입의 서브 타입이 된다.

바꿔말하면 이제 T는 무조건 Number 타입의 프로퍼티 length 를 가지고 있는 타입이 되어야 한다.

따라서 이렇게 extends를 이용해 타입 변수를 제한하면 아래와 같은 결과가 나타난다.

  • 1번 호출은 인수로 length 프로퍼티가 존재하는 string 타입의 값을 전달 했으므로 허용
  • 2번 호출은 인수로 length 프로퍼티가 존재하는 number[] 타입의 값을 전달 했으므로 허용
  • 3번 호출은 인수로 length 프로퍼티가 존재하는 객체 타입의 값을 전달 했으므로 허용
  • 4번 호출은 인수로 undefined을 전달했으므로 오류
  • 5번 호출은 인수로 null을 전달했으므로 오류

Map 메서드 타입 정의하기

map 메서드를 직접 함수로 만들고 타입도 정의하면 아래와 같이 된다.

function map(arr: unknown[], callback: (item: unknown) => unknown): unknown[] {}

메서드를 적용할 배열을 매개변수 arr로 받고, 콜백 함수를 매개변수 callback으로 받는다.

map 메서드는 모든 타입의 배열에 적용할 수 있기 때문에 arr의 타입은 unknown[]으로 정의하고, callback의 타입은 배열 요소 하나를 매개변수로 받아 특정 값을 반환하는 함수로 정의할 수 있다. 마지막으로 map 메서드의 반환값의 타입은 배열 타입으로 정의한다.

위 함수를 제네릭으로 변경하면 아래와 같다

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

map(arr, (it) => it * 2);
// number[] 타입의 배열을 반환
// 결과 : [2, 4, 6]

잘 동작하는 것 같습니다. 매개변수 arr에 number[] 타입의 배열을 제공하니 타입변수 T가 number로 추론되고 그 결과 map 함수의 반환값 타입도 number[]가 되었다!

그런데 문제는 함수 호출을 다음과 같이 수정하면 오류가 발생한다.

const arr = [1, 2, 3];

function map<T>(arr: T[], callback: (item: T) => T): T[] {
  (...)
}

map(arr, (it) => it.toString()); // ❌

콜백함수가 모든 배열 요소를 string 타입으로 변환하도록 수정하면 오류가 발생한다.

첫번째 인수로 arr을 전달했을 때 타입 변수 T에는 number 타입이 할당되었기 때문에 콜백 함수의 반환값 타입도 number 타입이 되어야 하기 때문인데, map 메서드는 이렇게 원본 배열 타입과 다른 타입의 배열로도 변환할 수 있어야 한다.

따라서 타입 변수를 하나 더 추가해 다음과 같이 수정하면 된다.

const arr = [1, 2, 3];

function map<T, U>(arr: T[], callback: (item: T) => U): U[] {
  (...)
}

map(arr, (it) => it.toString());
// string[] 타입의 배열을 반환
// 결과 : ["1", "2", "3"]

원본 배열의 타입과 새롭게 반환하는 배열의 타입을 다르게 설정해 주면 된다.

ForEach 메서드 타입 정의

ForEach 메서드는 Map 메서드보다 훨씬 만들기도 쉽고 타입 정의도 간단하다.

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

forEach(["123", "456"], (it) => it;) /// it: String

첫번째 매개변수 arr에는 순회 대상 배열을 제공받고 두번째 매개변수 callback에는 모든 배열 요소에 수행할 함수를 제공 받는다.

이때 아까 Map 메서드의 타입 정의와는 달리 forEach 메서드는 반환값이 없는 메서드이므로 콜백 함수의 반환값 타입을 void로 정의하면 된다.


제네릭 인터페이스, 제네릭 타입 별칭

제네릭 인터페이스

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

다음과 같이 변수의 타입으로 정의하여 사용할 수 있다.

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

let keyPair2: KeyPair<boolean, string[]> = {
  key: true,
  value: ["1"],
};

변수 keyPair의 타입으로 KeyPair<string, number>를 정의했다. 그 결과 K에는 string, V에는 number 타입이 각각 할당되어 key 프로퍼티는 string 타입이고 value 프로퍼티는 number 타입인 객체 타입이 된다. 따라서 값으로 해당 타입의 객체를 저장한다.

변수 keyPair2의 타입으로 KeyPair<boolean, string[]>를 정의했다. 그 결과 K에는 boolean, V에는 string[] 타입이 각각 할당되어 key 프로퍼티는 boolean 타입이고 value 프로퍼티는 string[] 타입인 객체 타입이 된다. 따라서 값으로 해당 타입의 객체를 저장한다.

이때 주의해야 할 점이 한가지 있는데 제네릭 인터페이스는 제네릭 함수와는 달리 변수의 타입으로 정의할 때 반드시 꺽쇠와 함께 타입 변수에 할당할 타입을 명시해주어야 한다.
그 이유는 제네릭 함수는 매개변수에 제공되는 값의 타입을 기준으로 타입 변수의 타입을 추론할 수 있지만 인터페이스는 마땅히 추론할 수 있는 값이 없기 때문이다.

인덱스 시그니쳐와 함께 사용하기

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

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

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

한개의 타입 변수 V를 갖는 제네릭 인터페이스 Map을 정의했다. 이 인터페이스는 인덱스 시그니쳐로 key의 타입은 string, value의 타입은 V인 모든 객체 타입을 포함하는 타입이다.

변수 stringMap의 타입을 Map<string> 으로 정의했다. 따라서 V가 string 타입이 되어 이 변수의 타입은 key는 string이고 value는 string인 모든 프로퍼티를 포함하는 객체 타입으로 정의된다.

변수 booleanMap의 타입을 Map<boolean> 으로 정의했다. 따라서 V가 boolean 타입이 되어 이 변수의 타입은 key는 string이고 value는 boolean인 모든 프로퍼티를 포함하는 객체 타입으로 정의된다.

제네릭과 인덱스 시그니처를 함께 사용하면 key, value의 상관없는 자유로운 타입 선언을 할 수 있다.

제네릭 타입 별칭

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

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

제네릭 타입 별칭을 사용할 때에도 타입으로 정의될 때 반드시 타입 변수에 설정할 타입을 명시해 주어야 한다.

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

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

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

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

function goToSchool(user: User<Student>) {
  // 타입가드
  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: "가톨릭대학교",
  },
};

두 타입 모두 String Literal 타입의 type 프로퍼티를 갖고 있으며, 서로소 유니온 타입이다.

그리고 그 아래에 학생일수도 개발자일 수도 있는 User 타입을 정의한다. 특정 객체가 학생이라면 profile 프로퍼티에 Student 타입의 객체가 저장될 것이고, 그렇지 않다면 Developer 타입의 객체가 저장될 것이다.

그 아래에 학생 유저만 이용할 수 있는 함수 goToSchool을 선언하고, 이 함수에서는 일단 User 타입의 객체를 받아 타입을 좁혀 이 유저가 학생일 때에만 “등교 완료”를 콘솔에 출력한다.

학생만 할 수 있는 기능이 점점 많아진다고 가정하면 매번 기능을 만들기 위해 함수를 선언할 때 마다 조건문을 이용해 타입을 좁혀야 하기 때문에 결국 매우 불편해 질 것 이고, 타입을 좁히는 코드는 중복 코드가 되어 함수마다 반복될 것이다.

이럴 때 바로 제네릭 인터페이스를 이용하면 좋다. 아래와 같이 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); // ❌ 컴파일에러 발생

그럼 이제 goToSchool 함수의 매개변수 타입을 User<Student> 처럼 정의해 학생 유저만 이 함수의 인수로 전달하도록 제한할 수 있다.

제네릭을 쓰면 변수를 만들 때 타입을 확정지어 버리기 때문에, 함수 내부에서 타입 좁히기 코드를 짤 필요가 없어서 훨씬 편하고 코드가 깔끔해진다.

제네릭으로 코드를 사용하게 되면 컴파일 단계에서 에러가 발생하기 때문에, 런타임에서 에러가 나는것을 방지하고, 휴먼에러를 방지할 수 있다!

제네릭 클래스

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]);

list 필드를 private(접근 제어자)으로 설정해 클래스 내부에서만 접근할 수 있도록 만들고, 생성자에서 필드 선언과 함께 초기화 한다.

그런데 만약 이때 StringList 클래스도 하나 필요하다면 어떻게 해야 할까? 제네릭 없이는 다음과 같이 새로운 클래스를 하나 더 만들어줘야 한다.

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

class StringList {
  constructor(private list: string[]) {}

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

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

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

const numberList = new NumberList([1, 2, 3]);
const stringList = new StringList(["1", "2", "3"]);

모든 리스트에 메서드가 새롭게 추가된다거나 동작이 수정되는 경우라도 생각하면 ….

따라서 이럴 때 다음과 같이 제네릭 클래스를 사용해 여러 타입의 리스트를 생성할 수 있는 범용적인 클래스를 정의하면 된다.

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]);
const stringList = new List(["1", "2"]);

클래스의 이름 뒤에 타입 변수를 선언하면 제네릭 클래스가 된다.

이 타입 변수는 이제 클래스 내부에서 자유롭게 사용할 수 있다. 또 클래스는 생성자를 통해 타입 변수의 타입을 추론할 수 있기 때문에 생성자에 인수로 전달하는 값이 있을 경우 타입 변수에 할당할 타입을 생략해도 된다.

만약 타입변수의 타입을 직접 설정하고 싶다면 다음과 같이 하면 된다.

class List<T> {
  constructor(private list: T[]) {}

  (...)
}

const numberList = new List<number>([1, 2, 3]);
const stringList = new List<string>(["1", "2"]);

Promise에서 제네릭 사용하기

Promise는 타입스크립트에서 제네릭 클래스로 구현되어 있기 때문에

새로운 Promise를 생성할 때 다음과 같이 타입 변수에 할당할 타입을 직접 설정해 주면 해당 타입이 바로 resolve 결과값의 타입이 된다.

아래에서 <number> 을 안넣게 되면 unknown 타입으로 추론된다

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

promise.then((response) => {
  // response는 number 타입
  console.log(response);
});

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

reject 함수에 인수로 전달하는 값 즉 실패의 결과값 타입은 정의할 수 없다. any타입으로 고정되어 있기 때문에 catch 메서드에서 사용하려면 타입 좁히기를 통해 안전하게 사용하는걸 권장한다고 한다.

Promise를 반환하는 함수의 타입

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

const postRequest = fetchPost();

postRequest.then((post) => {
  post.id; // ❌ unknown 타입이라 접근 불가
});

함수가 Promise 객체를 반환한다면 함수의 반환값 타입을 위해 다음과 같이 할 수 있다.

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


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

// 가독성이 좋아 추천!
// 또는 반환값 타입을 명시하는 것도 가능하다.
function fetchPost(): Promise<Post> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        id: 1,
        title: "게시글 제목",
        content: "게시글 본문",
      });
    }, 3000);
  });
}
profile
하고싶은거 짱많은 주니어 프론트엔드 개발자

0개의 댓글