하루살이 개발일지

[TypeScript] 제네릭(Generics)에 대하여 본문

웹개발/TypeScript

[TypeScript] 제네릭(Generics)에 대하여

harusari 2023. 6. 29. 18:20

제네릭(Generics)이란?

  • 제네릭 : 타입을 마치 함수의 파라미터처럼 사용하는 것
  • 한가지 타입보다 여러 가지 타입에서 동작하는 컴포넌트를 생성하는데 사용

 

제네릭의 예시

function getText(text) {
  return text;
}

getText('hi'); // 'hi'
getText(10); // 10
getText(true); // true
  • text라는 인자에는 string, number, boolean 등 어떤 값이 들어가더라도 그대로 반환됨
function getText<T>(text: T): T {
  return text;
}

getText<string>('hi');
getText<number>(10);
getText<boolean>(true);
  • 제네릭 기본 문법이 적용된 형태
  • 이제 함수를 호출할 때 함수 안에서 사용할 타입을 넘겨줄 수 있음

 

getText<string>('hi') 호출 시 제네릭 동작 과정

function getText<T>(text: T): T {
  return text;
}

getText<string>('hi');

이렇게 호출하면,

function getText<string>(text: T): T {
  return text;
}
  • 먼저 이런 과정을 거침
  • 함수에서 제네릭 타입이 string이 되는 이유 :
    • getText() 함수를 호출할 때 제네릭 값(=함수에서 사용할 타입)으로 string을 넘겨줬기 때문
function getText<string>(text: string): string {
  return text;
}
  • 그래서 결과적으로 이렇게 타입을 정의한 것과 같아짐
  • 이 함수는 입력 값의 타입이 string 이면서 반환값 타입도 string이어야 함

제네릭을 사용하는 이유

function logText(text: string): string {
  return text;
}
  • 인자를 하나 넘겨 받아 반환해주는 함수
  • 여기서 이 함수의 인자와 반환값은 모두 string으로 지정되어 있지만 만약 여러 가지 타입을 허용하고 싶으면 아래와 같이 any를 사용할 수 있음
function logText(text: any): any {
  return text;
}
  • 하지만 함수의 인자로 어떤 타입이 들어갔고 어떤 값이 반환되는지는 알 수 없음
  • any는 타입 검사를 하지 않기 때문

이러한 문제점을 해결할 수 있는게 제네릭

function logText<T>(text: T): T {
  return text;
}
  • 함수 이름 바로 뒤 <T>
  • 함수의 인자와 반환 값에 모두 T라는 타입을 추가
  • 이렇게 되면 함수를 호출할 때 넘긴 타입에 대해 타입스크립트가 추정할 수 있게 됨
  • 즉, 함수의 입력 값에 대한 타입과 출력 값에 대한 타입이 동일한지 검증할 수 있음

이렇게 선언한 함수는 아래와 같이 2가지 방법으로 호출할 수 있음

// #1
const text = logText<string>("Hello Generic");

// #2
const text = logText("Hello Generic");
  • 보통 두 번째 방법이 코드도 더 짧고 가독성이 좋아 흔하게 사용
  • 하지만, 만약 복잡한 코드에서 두 번째 코드로 타입 추정이 되지 않는다면 첫 번째 방법 사용하면 됨

제네릭 타입 변수

function logText<T>(text: T): T {
  return text;
}
  • 이와 같이 제네릭을 사용하기 시작하면 컴파일러에서 인자에 타입을 넣어달라는 경고를 보게 됨
  • 만약 여기서 함수의 인자로 받은 값의 length를 확인하고 싶다면 어떻게 해야 할까?
function logText<T>(text: T): T {
  console.log(text.length); // Error: T doesn't have .length
  return text;
}
  • 위 코드는 에러 발생
  • 왜냐하면 모든 T타입이 .length 속성을 가지는 것은 아니기 때문
  • 현재, 함수의 인자와 반환 값 타입에 마치 any를 지정한 것과 같이 동작
    • 그래서 당연히 인자에 number 타입 등을 넘길 수 있음
  • 이러한 특성 때문에 현재 인자인 text에 문자열이나 배열이 들어올 것이라고 하더라도 아직은 컴파일러 입장에서 .length 를 허용할 수 없음
    • 왜냐하면 number가 들어왔을 때는 .length 코드가 유효하지 않기 때문
function logText<T>(text: T[]): T[] {
  console.log(text.length); // 제네릭 타입이 배열이기 때문에 `length`를 허용합니다.
  return text;
}

logText([1, 2, 3]); // 여기서 T는 number
logText(['a', 'b', 'c']); // 여기서 T는 string
  • 그래서 이렇게 제네릭에 타입을 줄 수 있음
  • 위 코드가 기존 제네릭 코드와 다른 점은 인자의 T[] 부분
    • logText는 매개변수 text를 받음
    • text의 타입은 T타입의 배열
    • T는 logText를 호출할 때 결정됨
  • 즉 logText함수는 어떤 타입의 배열이든 받을 수 있고, 동일한 타입의 배열을 반환하도록 보장됨

아니면 이렇게 좀 더 명시적으로 제네릭 타입을 선언할 수 있음

function logText<T>(text: Array<T>): Array<T> {
  console.log(text.length);
  return text;
}
  • T[]와 Array<T>는 TypeScript에서 배열을 표현하는 두 가지 방법
  • 즉 이거랑 저거랑 완전히 동일한 코드이고 표현의 차이일 뿐

제네릭 타입

제네릭 인터페이스

function logText<T>(text: T): T {
  return text;
}
// #1
let str: <T>(text: T) => T = logText;

// #2
let str: {<T>(text: T): T} = logText;
  • 1번 코드와 2번 코드는 같은 의미
interface GenericLogTextFn {
  <T>(text: T): T;
}
function logText<T>(text: T): T {
  return text;
}
let myString: GenericLogTextFn = logText;

// **만약, 인터페이스에 인자 타입을 강조하고 싶다면**

interface GenericLogTextFn<T> {
  (text: T): T;
}
function logText<T>(text: T): T {
  return text;
}
let myString: GenericLogTextFn<string> = logText;
  • GenericLogTextFn : 제네릭 함수를 나타내는 인터페이스
    • T라는 제네릭 타입을 가진 함수를 정의
    • 이 함수는 T타입의 매개변수를 받아 T타입의 값을 반환
  • logText : T라는 제네릭 타입을 가진 함수
    • T타입의 매개변수 text를 받아 text를 그대로 반환
    • 이는 GenericLogTextFn 인터페이스와 일치
  • myString 변수 : logText 함수가 할당됨
    • myString의 타입은 GenericLogTextFn
    • 즉, myString은 T타입의 매개변수를 받아 T타입의 값을 반환하는 함수
  • 이렇게 인터페이스를 사용하면, 특정 형태를 가진 함수를 나타내는 타입을 쉽게 정의
  • 여기서는 GenericLogTextFn 인터페이스를 사용해 T타입의 매개변수를 받아 T타입의 값을 반환하는 함수의 타입을 정의함
  • 이렇게 인터페이스를 사용하면 코드가 읽기 쉽고 유지보수 편해짐
  • 이와 같은 방식으로 제네릭 인터페이스 뿐만 아니라 클래스도 생성 가능

제네릭 클래스

class GenericMath<T> {
  pi: T;
  sum: (x: T, y: T) => T;
}

let math = new GenericMath<number>();
  • 제네릭 클래스를 선언 시 클래스 이름 오른쪽에 <T>를 붙여줌
  • 그리고 해당 클래스로 인스턴스를 생성할 때 타입에 어떤 값이 들어갈 지 지정하면 됨

제네릭 제약 조건

function logText<T>(text: T): T {
  console.log(text.length); // Error: T doesn't have .length
  return text;
}
  • 인자의 타입에 선언한 T는 어떤 타입인지 구체적으로 정의하지 않았음 → length 코드에서 오류
  • 이 코드에서 length 속성 정도는 허용하려면 아래와 같음
interface LengthWise {
  length: number;
}

function logText<T extends LengthWise>(text: T): T {
  console.log(text.length);
  return text;
}

logText(3); // Error: Argument of type '3' is not assignable to parameter of type 'LengthWise'.
logText({ length: 3 }); // No error
  • extends 키워드
    • 제네릭 타입 변수가 특정 조건을 만족하도록 제한할 때 사용
    • <T extends LengthWise> : T타입이 LengthWise 인터페이스를 충족해야 한다는 것을 의미
  • LengthWise 인터페이스
    • length라는 number 타입의 속성을 가지는 객체를 나타냄
  • logText 함수
    • T라는 제네릭 타입을 가짐
      • T는 LengthWise 인터페이스를 충족하는 타입이어야 함
      • 즉, T는 length 속성을 가진 객체 타입이어야 함
  • 함수 내부에서 text.length를 출력할 때 T extends LengthWise로 인해 TypeScript가 text가 반드시 length 속성을 가질 것이라 가정하고, text.length에 접근하는 것이 안전하다고 판단
  • 이와 같이 extends를 사용하여 제네릭 타입에 제약조건을 두면, 제네릭 타입이 어떤 속성이나 메소드를 가질 것이라고 가정하고 코드를 작성할 수 있음

객체의 속성을 제약하는 방법 : keyof

두 객체를 비교할 때도 제네릭 제약 조건을 사용할 수 있음

function getProperty<T, O extends keyof T>(obj: T, key: O) {
  return obj[key];  
}
let obj = { a: 1, b: 2, c: 3 };

getProperty(obj, "a"); // okay
getProperty(obj, "z"); // error: "z""a", "b", "c" 속성에 해당하지 않습니다.
  • keyof 연산자
    • TypeScript에서 제공하는 인덱스 타입 쿼리 연산자
    • 어떤 타입의 키를 나타내는 타입을 생성
    • 예를 들어, { a: 1, b: 2, c: 3 } 타입의 keyof 결과는 "a" | "b" | "c"
  • O extends keyof T
    • O가 T의 키로서 존재해야 한다는 제약
    • 이런 제약 때문에, getProperty 함수에는 실제 객체의 속성 이름만을 인수로 전달할 수 있음
  • getProperty(obj, "a"); // okay
    • a는 obj의 키 중 하나이므로, 이 호출은 허용
    • obj["a"]를 반환
  • getProperty(obj, "z");
    • "z"는 obj의 어떠한 키와도 일치하지 않음
    • 따라서 O extends keyof T 제약조건에 위배
    • TypeScript 컴파일러는 이 호출에 대해 오류를 발생
  • 이런 방식으로 TypeScript의 제네릭과 keyof 연산자, 그리고 extends 키워드를 사용하면, 객체의 키를 안전하게 다룰 수 있음

(출처 : https://joshua1988.github.io/ts/guide/generics.html#제네릭-클래스)