TYPESCRIPT

[TypeScript] TypeScript와 제네릭

ch010104 2025. 6. 29. 18:08

1. 왜 제네릭이 필요한가?

// 제네릭
function func0(value: any){
    return value;
}

let num0 = func(10);

let bool0 = func(true);

let str0 = func("string");

// 이렇게 매개변수로 받은 타입의 변수를 리턴하는 함수를 만들고 싶을 때, any를 사용해서 할 순 있지만, 이는 위험함!!
// -> 이럴 때 사용하는 것이 제네릭 함수임!!
  • any는 유연하지만 타입 안정성을 잃음
  • unknown은 안전하지만 사용 전에 타입 좁히기가 필요!!
function func(value: unknown) {
  return value;
}

let num = func(10);
if (typeof num === "number") {
  num.toFixed(); // 안전하지만 번거로움
}

2. 제네릭 함수

// 제네릭 함수 -> 포괄적, 일반적 이라는 뜻!!
function func<T>(value: T): T { // func<타입 변수 T 선언>(매개변수 value를 타입 변수로 T로): 반환값도 타입 변수 T
  return value;
}

let num = func(10);
// num.toUpperCase();
num // 여기서 num은 number로 추론!

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

let bool = func(true);

let str = func("string");

let arr = func<[number, number, number]>([1, 2, 3]); // 타입 변수 T에 [number, number, number]가 들어감 -> arr가 [number, number, number]의 튜플 타입으로 추론 
// let arr = func([1, 2, 3] as [number, number, number]); 와 같음

// TypeScript에서 길이(length) 속성이 있는 값만 허용하는 함수
function getLength<T extends { length: number }>(data: T) { // 타입 변수 T에 extends를 통해 number 타입의 length 속성이 있는 값만 하용함.
    
  return data.length;
}
  • T는 함수 호출 시 자동으로 추론
  • 명시적으로 타입을 넘겨주는 것도 가능!

3. 제네릭 함수 활용 사례

1) 두 값을 스왑(swap)하기

// 첫번째 사례
function swap0(a: any, b: any) { // 이 방법은 불안정
  return [b, a];
}

function swap<T, U>(a: T, b: U) { // 입력 받은 첫번째 인자와 두번째 인자와 다를 경우에는 이렇게 선언
  return [b, a];
}

const [a, b] = swap("1", 2); // swap<string, number>(a: string, b: number): (string | number)[] 로 추론

2) 배열의 첫 번째 값을 반환하기

// 두번째 사례
function returnFirstValue0(data: any){ // 이 방법은 불안정
    return data[0]
}

function returnFirstValue1<T>(data: T[]) { // 그냥 data: T 로 할 경우, 아직 모르는 unknown에 data에 배열 data[0]를 쓰지 말라고 에러!!
  return data[0];
}

let num = returnFirstValue1([0, 1, 2]); // number 타입으로 정상 추론
// 0

3) 튜플로 첫 번째 타입만 반환하기

function returnFirstValue2<T>(data: [T, ...unknown[]]) { // 배열 타입이 아닌, 튜플 타입으로 작성!! -> 이 경우 str은 number로 추론
  return data[0];
}
let str = returnFirstValue2([1, "hello", "mynameis"]); // 이전과 같이 data: T[]만 할 경우 str은 (string | number)[]로 추론 됨. 
// -> [1, "hello", "mynameis"] 가 (string | number)[]이기 때문!!

4) 타입 제한 (Constraints)

// 세번째 사례 -> length 속성이 있는 타입만 매개변수로 받아 length를 반환
interface A {
  length: number;
}

interface B extends A {} // T extends { length: number } 와 같음

function getLength0(data: any){ // 이 방법은 불안정
    return data.length;
}

function getLength<T extends { length: number }>(data: T) { // T의 타입을 number 타입의 length 속성을 가지고 있는 타입을 확장
  return data.length;
} 

getLength("123");

getLength([1, 2, 3]);

getLength({ length: 1 });

// getLength(undefined);

// getLength(null);
  • extends를 사용하여 특정 속성이 있는 타입만 받도록 제한할 수 있음

4. map 함수 직접 구현해보기

// map 메서드
const arr = [1, 2, 3];
const newArr = arr.map((it) => it * 2); // (it) => it * 2 라는 콜백 함수를 arr의 모든 요소에 적용해서 새로운 배열 newArr를 만듬 -> it은 number 타입으로 추론
// [2, 4, 6]

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

map0(["hi", "hello"], (it) => it.toUpperCase()); // 정상 작동
// map0(["hi", "hello"], (it) => parseInt(it)); // number타입으로 반환된 값을 T 타입인 string에 넣으려해서 에러!! 

function map<T, U>(arr: T[], callback: (item: T) => U) { // 2개의 타입 변수 T, U를 사용
  let result = [];
  for (let i = 0; i < arr.length; i++) {
    result.push(callback(arr[i]));
  }
  return result;
}

map(arr, (it) => it * 2);
map(["hi", "hello"], (it) => parseInt(it)); // 이 경우, T에는 string 타입, U에는 number 타입이 들어가서 정상적으로 작동

5. forEach 함수 직접 구현

// forEach
const arr2 = [1, 2, 3];
arr2.forEach((it) => console.log(it)); // arr의 각 요소에 콜백 함수 (it) => console.log(it) 를 반복 수행

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

forEach(arr2, (it) => { // arr2 가 number 타입이면 it 또한 number 타입이 되어 toFixed를 사용할 수 있어야함. -> it에 number로 정상 추론
  console.log(it.toFixed());
});

forEach(["123", "456"], (it) => {
  it;
});

6. 제네릭 인터페이스

// 제네릭 인터페이스 -> 제네릭 함수를 만들 때와 유사
interface KeyPair<K, V> {
  key: K;
  value: V;
}

let keyPair: KeyPair<string, number> = { // 타입으로 정의할 때, KeyPair<string, number>처럼 매개변수의 타입을 선언해야함
  key: "key", 
  value: 0,
};

let keyPair2: KeyPair<boolean, string[]> = {
  key: true,
  value: ["1"],
};
  • 제네릭 인터페이스는 반드시 타입 인자를 명시!!

1) 인덱스 시그니처와 함께

// 인덱스 시그니쳐
interface NumberMap {
  [key: string]: number; // key의 타입은 string, value의 타입은 number 
}

let numberMap1: NumberMap = {
  key: -1231,
  key2: 123123,
};

interface Map<V> {
  [key: string]: V; // value의 타입을 바꾸어 사용 가능
}

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

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

2) 제네릭 타입 별칭

// 제네릭 타입 별칭 
type Map2<V> = {
  [key: string]: V;
};

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

3) 실용 예제: 학생과 개발자 구분

// 제네릭 인터페이스의 활용 예시
// -> 유저 관리 프로그램
// -> 유저 구분 : 학생 유저 / 개발자 유저
interface Student {
  type: "student";
  school: string;
}

interface Developer { // student와 developer는 서로 서로소 관계임
  type: "developer";
  skill: string;
}

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

interface User0{
  name: string;
  profile: Student | Developer; // 이 것과 같은 기능
}

function goToSchool0(user: User0) { // 학생 유저만 가능
  if(user.profile.type !== "student"){
    console.log("잘못 오셨습니다");
    return;
  }

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

function goToSchool(user: User<Student>) { // 학생 유저만 가능 -> 제네릭 인터페이스를 사용함
  const school = user.profile.school;
  console.log(`${school}로 등교 완료`);
}

// goToSchool(developerUser); developer 타입의 유저는 goToSchool의 함수의 매개변수로 올 수 없음!!

const developerUser: User<Developer> = {
  name: "이정환",
  profile: {
    type: "developer",
    skill: "TypeScript",
  },
};

const studentUser: User<Student> = {
  name: "홍길동",
  profile: {
    type: "student",
    school: "가톨릭대학교",
  },
};
  • 타입 좁히기 없이 명확한 분리와 제한이 가능해짐

7. 제네릭 클래스

// 제네릭 클래스
class List<T> {
  constructor(private list: T[]) {} // 생성자에서 타입을 정의해서 필드를 생략 -> private 타입의 경우에는 this.list = list 또한 생략 가능

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

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

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

const numberList = new List<number>([1, 2, 3]); // 매개변수로 number가 들어오면 number의 리스트
numberList.pop(); // 3 제거
numberList.push(4); // 4 추가
numberList.print(); // [1, 2, 4]

const stringList = new List<string>(["1", "2"]); // 매개변수로 string이 들어오면 string의 리스트
stringList.push("hello");
stringList.print(); // ['1', '2', 'hello']

8. Promise는 제네릭 클래스

// 프로미스
const promise0 = new Promise((resolve, reject) => {
    setTimeout(()=>{
        resolve(20); // 비동기 작업의 결과값 20
    }, 3000);
});

// promise0.then((response) => { // response의 타입이 unknown이기 때문에 * 10 에서 에레가 발생!!
//   console.log(response * 10); // -> 타입 좁히기를 써도 되지만, 번거로움 -> 제네릭 사용!!
// });

const promise = new Promise<number>((resolve, reject) => { // number 타입으로 선언
  setTimeout(() => {
    // resolve(20); // -> 비동기 작업의 결과값으로 number 만 가능
    reject("~~ 때문에 실패");
  }, 3000);
});

promise.then((response) => { // 성공할 때
  console.log(response * 10); // 20
});

promise.catch((err) => { // 실패할 때 -> 매개변수 err의 타입을 잘 알 수 없음
  if (typeof err === "string") {
    console.log(err);
  }
});
// -> 즉, Promise에서 성공했을 때의 타입은 Promise<number> 에서 정해줄 수 있지만, 실패했을 때의 타입은 전해줄 수 없음

 

1) Promise를 반환하는 함수

// 프로미스를 반환하는 함수의 타입을 정의
interface Post {
  id: number;
  title: string;
  content: string;
}

function fetchPost(): Promise<Post> { // Promise<Post> 로 함수의 반환 타입을 명시
  return new Promise<Post>((resolve, reject) => { // Promise<Post>, <Post> 가 없을 경우에는 unknown으로 추론하기 때문에 이후에 post.id와 같이 속성에 접근 불가
    setTimeout(() => {
      resolve({
        id: 1,
        title: "게시글 제목",
        content: "게시글 컨텐츠",
      });
    }, 3000);
  });
}

const postRequest = fetchPost();

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

 


9. 깃허브 코드 내용