제네릭(Generics)

Donggu(oo)·2023년 6월 25일

TypeScript

목록 보기
6/8
post-thumbnail

1. 제네릭이란?


  • 제네릭(Generics)이란 어떤 함수나 클래스가 사용할 타입을 생성 단계가 아닌 사용 단계에서 정의하는 프로그래밍 기법을 말한다. 즉, 타입을 명시할 때 선언 시점이 아닌 생성 시점에 명시하여 하나의 타입으로만 사용하지 않고 다양한 타입을 사용할 수 있다.

  • 제네릭은 꺽쇠(<>)와 식별자를 입력해 제네릭을 만들 수 있다.

  • 예를 들어 문자를 그대로 반환하는 함수에 아래와 같이 제네릭을 적용할 수 있다.

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

getText<string>('hi');
getText<number>(10);
getText<boolean>(true);
  • getText<string>('hi')을 호출 했을 때 getText 함수는 입력 값의 타입이 string이고, 반환 값의 타입도 string으로 정의한 것과 같다.
function getText<string>(text: string) {
  return text;
}
  • 여기서 <T>의 문자는 다른 문자로 작성해도 된다. 대부분 T로 작성하는 이유는 T가 Type의 약자이기 때문이다. 하지만 어떤 식별자도 상관이 없어, 필드 이름의 첫 글자를 사용하기도 합니다.

  • 또한 제네릭 인터페이스 코드를 아래와 같이 작성할 수 있다.

interface GenericLogTextFn {
  <T>(text : T): T;
}

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

let myString : GenericLogTextFn = logText; // Okay

// 인터페이스에 인자 타입을 강조
interface GenericLogTextFn<T> {
  (text : T): T;
}

function logText<T>(text : T) {
  return text;
}
let myString : GenericLogTextFn<string> = logText;
  • 이와 같은 방식으로 제네릭 인터페이스 뿐만 아니라 클래스도 생성할 수 있다. 그러나 이넘(enum)과 네임스페이스(namespace)는 제네릭으로 생성할 수 없다.

2. 제네릭을 사용하는 이유


1) 재사용성이 높은 함수와 클래스를 생성할 수 있다.

  • 여러 타입에서 동작이 가능하다. (한 번의 선언으로 다양한 타입에 재사용할 수 있다.)

  • 위의 getText 함수에서 제네릭을 사용하지 못했다면 타입별로 하나의 함수를 만들어야 하지만 제네릭을 사용하면 하나만 선언햇도 여러 타입에서 동작이 가능한다.

2) 오류를 쉽게 포착할 수 있다.

  • any 타입을 사용하면 컴파일 시 타입을 체크하지 않는다. 타입을 체크하지 않게 되면 컴파일 시에 컴파일러가 오류를 찾지 못한다.

  • 제네릭도 any처럼 미리 타입을 지정하지는 않지만, 타입을 체크해 컴파일러가 오류를 찾을 수 있다.

2-1. 첫 번째 예제

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

function logText(text: any) {
  return text;
}
  • 이렇게 타입을 바꾼다고 해서 함수의 동작에 문제가 생기진 않지만, any 타입은 타입 검사를 하지 않기 때문에 함수의 인자로 어떤 타입이 들어갔고 어떤 값이 반환되는지 알 수 없다.

  • 이러한 문제를 해결하기 위해 제네릭을 사용할 수 있다. 아래의 코드에서 함수의 이름 바로 뒤에 <T>라는 코드를 추가했다. 그리고 함수의 인자와 반환 값에 모두 T라는 타입을 추가한다. 이렇게 되면 함수를 호출할 때 넘긴 타입에 대해 TypeScript가 추정할 수 있게 된다. 따라서 함수의 입력 값에 대한 타입과 출력 값에 대한 타입이 동일한지 검증할 수 있게 된다.

function logText<T>(text: T) {
  return text;
}
  • 그리고 이렇게 선언한 함수는 아래과 같이 2가지 방법으로 호출할 수 있다. 보통 두 번째 방법이 코드도 더 짧고 가독성이 좋기 때문에 자주 사용된다. 그러나 복잡한 코드에서 두 번째 코드로 타입 추정이 되지 않는다면 첫 번째 방법을 사용한다.
// #1
const text = logText<string>("Hello Generic");
// #2
const text = logText("Hello Generic");

2-2. 두 번째 예제

  • 아래는 any[] 타입의 값을 전달하는 함수의 예시이다. 이 방법의 문제는 배열의 아이템으로 문자열이든 숫자든 아무 값이나 추가할 수 있다는 점이다. 이때 제네릭을 사용하면 일괄된 타입의 값을 처리할 수 있다.
  function arrayConcat(items1: any[], items2: any[]) {
    return items1.concat(items2);
  }

  let arr1 = arrayConcat([10, 20, 30], ["a", "b", 40]);
  arr1.push(true);  // 어떤 값이라도 추가할 수 있다.
  • 아래는 제네릭을 사용하도록 arrayConcat을 변경한 것이다. 제네릭 타입을 number로 지정하여 호출했기 때문에 전달되는 인자와 리턴값 모두 number[] 형식이다. 따라서 arr.push("hello")와 같이 number가 아닌 다른 형식의 값을 추가하는 것을 허용하지 않는다.
  function arrayConcat2<T>(items1: T[], items2: T[]) {
    return items1.concat(items2);
  }

  let arr1 = arrayConcat2<number>([10, 20, 30], [40, 50]);
  arr1.push("hello"));  // arr2는 number[] 형식이므로 명시적 에러 발생

3. 제네릭 타입 변수


  • 아래의 함수에서 인자로 받은 값의 length를 확인하려고 아래와 같이 적성한다면 컴파일러에서 에러를 발생시킬 것이다. 왜냐하면 text.length가 없기 때문이다.

  • 아래 제네릭 코드의 의미를 살펴보면 함수의 인자와 반환 값에 대한 타입을 정하진 않았지만, 입력 값으로 어떤 타입이 들어왔고 반환 값으로 어떤 타입이 나가는지 알 수 있다. 따라서, 함수의 인자와 반환 값 타입에 마치 any를 지정한 것과 같은 동작을 한다는 것을 알 수 있다.

  • 그래서 만약 인자에 number 타입을 넘기더라도 에러가 나진 않는다. 이러한 특성 때문에 현재 인자인 text에 문자열이나 배열이 들어와도 아직은 컴파일러 입장에서 .length를 허용하지 않는다. 왜냐하면 number가 들어왔을 때는 .length 코드가 유효하지 않기 때문이다.

function logText<T>(text: T) {
  console.log(text.length); // Error: T doesn't have .length
  return text;
}
  • 아래 코드가 기존의 제네릭 코드와 다른 점은 인자의 T[] 부분이다. 이 제네릭 함수 코드는 T라는 변수 타입을 받고, 인자 값으로는 배열 형태의 T를 받는다. 예를 들면, 함수에 [1,2,3]처럼 숫자로 이뤄진 배열을 받으면 반환 값으로 number를 돌려주는 것이다. 이런 방식으로 제네릭을 사용하면 꽤 유연한 방식으로 함수의 타입을 정의해줄 수 있다.
function logText<T>(text: T[]) {
  console.log(text.length);  // 제네릭 타입이 배열이기 때문에 `length`를 허용한다.
  return text;
}
  • 위의 예제에서 인자의 타입에 선언한 T는 아직 어떤 타입인지 구체적으로 정의하지 않았기 때문에 length 코드에서 오류가 난다. 이럴 때 만약 해당 타입을 정의하지 않고도 length 속성 정도는 허용하려면 아래와 같이 작성할 수 있다.
interface LengthWise {
  length : number;
}

function logText<T extends LengthWise>(text : T) {
  console.log(text.length);
  return text;
}
  • 위와 같이 작성하면 타입에 대한 강제는 아니지만 length에 대해 동작하는 인자만 넘겨받을 수 있게 된다.
logText(10); // Error, 숫자 타입에는 `length`가 존재하지 않으므로 오류 발생
logText({ length: 0, value: 'hi' }); // `text.length` 코드는 객체의 속성 접근과 같이 동작하므로 오류 없음

5. 제네릭 제약 조건


  • 제네릭에는 원하지 않는 속성의 접근을 막기 위해 Constraintskeyof 2가지 제약조건을 사용할 수 있다.

1) Constraints

  • 특정 타입들로만 동작하는 Generic 함수를 만들 때 사용한다. Generic T에 제약 조건을 설정한다.(문자열 or 숫자)

  • 제약 조건을 벗어나는 타입을 선언하면 에러가 발생한다.

const printMessage = <T extends string | Number>(message: T) => {
  return message;
};

printMessage<string>('1');
printMessage<Number>(1);
printMessage<Boolean>(false);  // Error: Type 'Boolean' does not satisfy the constraint 'string | Number'.

2) keyof

  • 객체의 속성을 제약할 수도 있는데 두 객체를 비교할 때도 제네릭 제약 조건을 사용할 수 있다. 아래의 코드는 제네릭을 선언할 때 <O extends keyof T> 부분에서 첫 번째 인자로 받는 객체에 없는 속성들은 접근할 수 없게끔 제한하였다.
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" 속성에 해당하지 않습니다.
profile
FE Developer

0개의 댓글