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

김태완·2024년 11월 21일
post-thumbnail

제네릭이란

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

제네릭이 필요한 상황

  • 다음과 같이 다양한 타입의 매개변수를 받고 해당 매개변수를 그대로 반환하는 함수가 하나 필요하다고 가정해보자.
  • 다양한 타입의 매개변수를 제공받아야 하기 때문에 매개변수 value의 타입을 일단 any 타입으로 했다. 물론 unknown으로 정의해도 괜찮다.
  • 이 함수는 인수로 전달한 값을 그냥 그대로 반환하는 단순한 함수이다.
  • 따라서 변수 num에는 10이 저장되고, 변수 str에는 “string”이 저장된다. 그런데 현재 num과 str의 타입은 any 타입이 된다. func 함수의 반환값 타입이 return 문을 기준으로 추론되었기 때문이다.
  • 이렇게 함수 호출 결과를 저장하는 num, str 등의 변수가 any 타입으로 추론되면 다음과 같은 문제점이 발생한다.
  • num에는 분명 Number 타입의 값 10이 저장되어 있지만, any 타입으로 추론되어 버렸기 때문에 toUpperCase 등의 String 타입의 메서드를 사용해도 타입스크립트가 오류를 감지하지 못한다.
  • 결국 실제로 실행하면 런타임 오류를 발생시킬 것이다.
  • 이번에는 매개변수의 타입을 any 타입 말고 unknown 타입으로 정의해보자.
  • 그러면 toUpperCase 같은 메서드 호출은 방지할 수 있다.
  • 그러나 이번에는 또 다른 문제가 발생한다. toFixed 같은 Number 타입의 메서드 호출도 함께 오류로 판단한다.
    • 왜냐하면 unknown 타입은 어떤 연산, 어떤 메서드도 할 수 없는 전체집합이잖아.
  • 따라서 num에 10이 저장될 것이 분명한데도 이 값을 사용하려면 다음과 같이 비효율적으로 타입 좁히기를 이용해야 한다.
  • 그냥 인수로 Number 타입의 값을 전달하면 반환 타입이 Number가 되고, 인수로 String 타입의 값을 전달하면 반환값의 타입도 String 타입이 되었으면 좋겠을때, 제네릭을 사용하면 된다.

제네릭(Generic) 함수

  • 제네릭(Generic) 함수란 무엇일까? 일단 제네릭이란 일반적인 또는 포괄적인 이라는 뜻을 갖고 있다. 따라서 제네릭 함수는 일반적인 함수 또는 포괄적인 함수 정도로 해석할 수 있다.
  • 따라서 '제네릭 함수는 두루두루 모든 타입의 값을 다 적용할 수 있는 그런 범용적인 함수이다' 정도로 이해할 수 있다.
  • 함수 이름 뒤에 꺽쇠를 열고 타입을 담는 변수인 타입 변수 T를 선언한다. 그리고 매개변수와 반환값의 타입을 이 타입변수 T로 설정한다.
  • T에 어떤 타입이 할당될 지는 함수가 호출될 때 결정된다.
  • func(10) 처럼 Number 타입의 값을 인수로 전달하면 매개변수 value에 Number 타입의 값이 저장되면서 T가 Number 타입으로 추론된다. 이때 T가 Number 타입으로 추론된다. 그럼 이때의 func 함수의 반환값 타입또한 Number 타입이 된다.
  • 제네릭 함수를 호출할 때 다음과 같이 타입 변수에 할당할 타입을 직접 명시하는 것도 가능하다.
    • 위 코드의 흐름은 다음과 같다.
    • T에 [Number, Number, Number] 튜플 타입이 할당된다.
    • 매개변수 value와 반환값 타입이 모두 튜플 타입이 된다.
    • 만약 위 코드에서 타입 변수에 할당할 타입을 튜플 타입으로 설정하지 않았다면 T가 number[] 타입으로 추론 되었을 것이다.
    • 타입스크립트는 타입을 추론할 때 항상 일반적이고 좀 더 범용적인 타입으로 추론하기 때문이다.
    • 이렇듯 타입 변수에 할당하고 싶은 특정 타입이 존재한다면 함수 호출과 함께 꺽쇠를 열고 직접 명시해주는게 좋다.
    • 그렇지 않은 대다수의 상황에서는 알아서 잘 추론되기 때문에 굳이 타입 변수를 설정하지 않아도 된다.

타입 변수 응용하기

사례 1.

  • 만약 2개의 타입 변수가 필요한 상황이라면, 다음과 같이 T, U 처럼 2개의 타입 변수를 사용해도 된다.
  • 따라서 위 코드에서 T는 String 타입으로 U는 Number 타입으로 추론된다.

사례 2.

  • 다양한 배열 타입을 인수로 받는 제네릭 함수를 만들어야 한다면 다음과 같이 할 수 있다.
  • 함수 매개변수 data의 타입을 T[]로 설정했기 때문에 배열이 아닌 값은 인수로 전달할 수 없다. 배열을 인수로 전달하면 T는 배열의 요소 타입으로 할당된다.
  • 첫번째 호출에서는 인수로 Number[] 타입의 값을 전달했으므로 이때의 T는 Number 타입으로 추론된다. 이때의 함수 반환값 타입은 Number 타입이 된다.
  • 두번째 호출에서는 인수로 (String | Number)[] 타입의 값을 전달했으므로 이때의 T는 String | Number 타입으로 추론된다. 이때의 함수 반환값 타입은 String | Number 타입이 된다.

사례 3.

  • 그런데 위 사례에서 만약 반환값의 타입을 배열의 첫번째 요소의 타입이 되도록 하려면, 다음과 같이 튜플 타입과 나머지 파라미터를 이용하면 된다.
  • 함수 매개변수의 타입을 정의할 때 튜플 타입을 이용해 첫번째 요소의 타입은 T, 그리고 나머지 요소의 타입은 …unknown[] 으로 길이도 타입도 상관 없도록 정의한다.
  • 함수를 호출하고 [1, “hello”, “mynameis”] 같은 배열 타입의 값을 인수로 전달하면 T는 첫번째 요소의 타입인 Number 타입이 된다. 따라서 함수 반환값 타입또한 Number 타입이 된다.

사례 4.

  • 타입 변수를 제한하는 사례이다.
  • 타입 변수를 제한한다는 것은 함수를 호출하고 인수로 전달할 수 있는 값의 범위에 제한을 두는 것을 의미한다.
  • 다음은 타입 변수를 적어도 length 프로퍼티를 갖는 객체 타입으로 제한한 예시이다.
  • 타입 변수를 제한할 때에는 확장(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, forEach 메서드 타입 정의하기

Map 메서드 타입 정의하기

  • 자바스크립트의 배열 메서드 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[] {}
  • 모든 unknown 타입을 타입 변수 T로 대체한다. 다음으로는 함수 내부를 구현한다.
  • 매개변수 arr에 number[] 타입의 배열을 제공하니 타입변수 T가 number로 추론되고 그 결과 map 함수의 반환값 타입도 number[]가 되었다.
  • 그런데 함수 호출부분을 아래와 같이 한다면 오류가 발생한다.
    map(arr, (it) => it.toString()); // ❌
  • 콜백함수가 모든 배열 요소를 String 타입으로 변환하도록 수정했다. 이러면 오류가 발생한다.
  • 첫번째 인수로 arr을 전달했을때 타입 변수 T에는 number 타입이 할당되었기 때문에 콜백 함수의 반환값 타입도 number 타입이 되어야 하기 때문이다.
  • 그런데 map 메서드는 이렇게 원본 배열 타입과 다른 타입의 배열로도 변환할 수 있어야 한다. 따라서 타입 변수를 하나 더 추가해 다음과 같이 수정한다.
  • 원본 배열의 타입과 새롭게 반환하는 배열의 타입을 다르게 설정해주니 오류가 없어졌다!

ForEach 메서드 타입 정의하기

  • forEach 메서드는 다음과 같이 배열의 모든 요소에 콜백함수를 한번씩 수행해주는 메서드이다.
  • ForEach 메서드는 Map 메서드보다 훨씬 만들기도 쉽고 타입 정의도 간단하다.
  • Map과 동일하게 2개의 매개변수를 받는다.
  • 첫번째 매개변수 arr에는 순회 대상 배열을 제공받고, 두번째 매개변수 callback에는 모든 배열 요소에 수행할 함수를 제공 받는다.
  • 이때 아까 Map 메서드의 타입 정의와는 달리 forEach 메서드는 반환값이 없는 메서드이므로 콜백 함수의 반환값 타입을 void로 정의한다.

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

제네릭 인터페이스

  • 제네릭은 인터페이스에도 적용할 수 있다. 다음과 같이 인터페이스에 타입 변수를 선언해 사용한다.
  • 키페어를 저장하는 객체의 타입을 제네릭 인터페이스로 정의했다.
  • 다음과 같이 변수의 타입으로 정의하여 사용할 수도 있다.
  • 변수 keyPair의 타입으로 KeyPair<string, number>를 정의했다. 그 결과 K에는 string, V에는 number 타입이 각각 할당되어 key 프로퍼티는 string 타입이고, value 프로퍼티는 number 타입인 객체 타입이 된다. 따라서 값으로 해당 타입의 객체를 저장한다.
  • 변수 keyPair2의 타입으로 KeyPair<boolean, string[]>를 정의했다. 그 결과 K에는 boolean, V에는 string[] 타입이 각각 할당되어 key 프로퍼티는 boolean 타입이고, value 프로퍼티는 string[] 타입인 객체 타입이 된다. 따라서 값으로 해당 타입의 객체를 저장한다.
  • 이때 주의해야 할 점은 제네릭 인터페이스는 제네릭 함수와는 달리 변수의 타입으로 정의할 때, 반드시 꺽쇠와 함께 타입 변수에 할당할 타입을 명시해주어야 한다.
  • 그 이유는 제네릭 함수는 매개변수에 제공되는 값의 타입을 기준으로 타입 변수의 타입을 추론할 수 있지만, 인터페이스는 마땅히 추론할 수 있는 값이 없기 때문이다.

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

  • 제네릭 인터페이스는 인덱스 시그니쳐와 함께 사용하면 다음과 같이 기존보다 훨씬 더 유연한 객체 타입을 정의할 수 있다.
  • 한개의 타입 변수 V를 갖는 제네릭 인터페이스 Map을 정의했다.
  • 이 인터페이스는 인덱스 시그니쳐로 key의 타입은 string, value의 타입은 V인 모든 객체 타입을 포함하는 타입이다.
  • 변수 stringMap의 타입을 Map<string> 으로 정의했다. 따라서 V가 string 타입이 되어 이 변수의 타입은 key는 string이고 value는 string인 모든 프로퍼티를 포함하는 객체 타입으로 정의된다.
  • 변수 booleanMap의 타입을 Map<boolean> 으로 정의했다.
  • 따라서 V가 boolean 타입이 되어 이 변수의 타입은 key는 string이고, value는 boolean인 모든 프로퍼티를 포함하는 객체 타입으로 정의된다.

제네릭 타입 별칭

  • 인터페이스와 마찬가지로 타입 별칭에도 역시 제네릭을 적용할 수 있다.
  • 제네릭 타입 별칭을 사용할 때도 제네릭 인터페이스와 마찬가지로 타입으로 정의될 때 반드시 타입 변수에 설정할 타입을 명시해 주어야한다.

제네릭 클래스

  • 먼저 제네릭이 아닌 간단한 Number 타입의 리스트를 생성하는 클래스를 하나 만든다.
  • list 필드를 private(접근 제어자)으로 설정해 클래스 내부에서만 접근할 수 있도록 만들고, 생성자에서 필드 선언과 함께 초기화한다. 새로운 요소를 추가하는 push, 제거하는 pop, 출력하는 print 메서드도 만들었다.
  • 그런데 만약 이때 StringList 클래스도 하나 필요하다면 어떻게 해야 할까? 제네릭 없이는 어쩔 수 없이 새로운 클래스를 하나 더 만들어줘야 한다. 이는 굉장히 비효율적이다.
  • 이럴 때 다음과 같이 제네릭 클래스를 사용해 여러 타입의 리스트를 생성할 수 있는 범용적인 클래스를 정의하면된다.
  • 클래스의 이름 뒤에 타입 변수를 선언하면 제네릭 클래스가 된다.
  • 이 타입 변수는 이제 클래스 내부에서 자유롭게 사용할 수 있다.
  • 또 클래스는 생성자를 통해 타입 변수의 타입을 추론할 수 있기 때문에 생성자에 인수로 전달하는 값이 있을 경우 타입 변수에 할당할 타입을 생략해도 된다.
  • 만약 타입변수의 타입을 직접 설정하고 싶다면 다음과 같이 하면 된다.

Promise 사용하기

  • Promise는 제네릭 클래스로 구현되어 있다. 따라서 새로운 Promise를 생성할 때 다음과 같이 타입 변수에 할당할 타입을 직접 설정해 주면 해당 타입이 바로 resolve 결과값의 타입이 된다.
  • reject 함수에 인수로 전달하는 값, 즉 실패의 결과값 타입은 정의할 수 없다. 그냥 unknown 타입으로 고정되어 있기 때문에 catch 메서드에서 사용하려면 타입 좁히기를 사용하는걸 권장한다.
  • 만약 어떤 함수가 Promise 객체를 반환한다면 함수의 반환값 타입을 위해 다음과 같이 할 수 있다.
  • 또는 더 직관적으로 다음과 같이 반환값 타입을 직접 명시해도 된다. (추천한다!)

이미지 출처: <한 입 크기로 잘라먹는 타입스크립트>
https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8?inst=68d752f7&utm_source=instructor&utm_medium=referral&utm_campaign=inflearn_%ED%8A%B8%EB%9E%98%ED%94%BD_promotion-link

profile
중고

0개의 댓글