[LearningTS] Chapter10. 제네릭
포스트
취소

[LearningTS] Chapter10. 제네릭

CHAPTER 10. 제네릭

1
2
타입 시스템에서 선언된 변수는
왼전히 새롭게 타입된 세계가 됩니다!
  • 타입스크립트는 제네릭을 사용해 타입 간의 관계를 알아낸다.
  • 타입스크립트에서 함수와 같은 구조체는 제네릭 타입 매개변수를 원하는 수만큼 선언할 수 있다.
  • 제네릭 타입 매개변수는 제네릭 구조체의 각 사용법에 따라 타입이 결정된다.
  • 이러한 타입 매개변수는 구조체의 각 인스턴스에서 서로 다른 일부 타입을 나타내기 위해 구조체의 타입으로 사용된다.
  • 타입 매개변수는 구조체의 각 인스턴스에 대해 타입 인수라고 하는 서로 다른 타입을 함께 제공할 수 있지만, 타입 인수가 제공된 인스턴스 내에서는 일관성을 유지한다.
  • 타입 매개변수는 전형적으로 TU 같은 단일 문자 이름 또는 KeyValue 같은 파스칼 케이스 이름을 갖는다.


🌓 제네릭 함수

  • 매개변수 괄호 바로 앞 홑화살괄호(<, >)로 묶인 타입 매개변수에 별칭을 배치해 함수를 제네릭으로 만든다.
  • 그러면 해당 타입 매개변수를 함수 본문 내부의 매개변수 타입 애너테이션, 반환 타입 애너테이션, 타입 애너테이션, 타입 애너테이션에서 사용할 수 있다.

[예제]

  • identity 함수는 input 매개변수에 대한 타입 매개변수 T를 선언한다.
  • 이를 통해 타입스크립트는 함수의 반환 타입이 T임을 유추한다.
  • 그러면 타입스크립트는 identity가 호출될 때마다 T에 대한 다른 타입을 유추할 수 있다.
1
2
3
4
5
6
function identity<T>(input: T) {
  return input;
}

const numeric = identity('me'); // 타입: "me"
const stringy = identity(123); // 타입: 123

화살표 함수도 제네릭을 만들 수 있으며, 화살표 함수의 제네릭 선언은 매개변수 목록 바로 전인 ( 앞에 위치한다.

1
2
3
4
// 위와 동일한 함수 선언식
const identity = <T>(input: T) => input;

identity(123); // 타입: 123

🌿 제네릭 화살표 함수 구문은 .tsx 파일에서 JSX 구문과 충돌하므로 일부 제한이 있다.

이러한 방식으로 함수에 타입 매개변수를 추가하면 타입 안정성을 유지하고 any 타입을 피하면서 다른 입력과 함께 재사용할 수 있다.


1. 명시적 제네릭 호출 타입

  • 제네릭 함수를 호출할 때 대부분의 타입스크립트는 함수가 호출되는 방식에 따라 타입 인수를 유추한다.
  • 예를 들어, 이전 예전의 identity 함수에서 타입스크립트의 타입 검사기는 identity에 제공된 인수를 사용해 해당 함수 매개변수의 타입 인수를 유추한다.
  • 하지만 클래스 멤버와 변수 타입과 마찬가지로 때는 타입 인수를 해석하기 위해 타입스크립트에 알려줘야 하는 함수 호출 정보가 충분하지 않을 수도 있다.
  • 이러한 현상은 타입 인수를 알 수 없는 제네릭 구문에 제공된 경우 주로 발생한다.
  • 기본값이 unknown으로 설정되는 것을 피하기 위해 타입스크립트에 해당 타입 인수가 무엇인지 명시적으로 알려주는 명시적 제네릭 타입 인수를 사용해 함수를 호출할 수 있다.
  • 타입스크립트는 매개변수가 타입 인수로 제공된 것과 일치하는지 확인하기 위해 제네릭 호출에서 타입 검사를 수행한다.
  • 변수에 대한 명시적 타입 애너테이션과 마찬가지로 명시적 타입 인수는 항상 제네릭 함수에 지정할 수 있지만 때로는 필요하지 않는다.


2. 다중 함수 타입

  • 임의의 수의 타입 매개변수를 쉼표로 구분해 함수를 정의한다.
  • 제네릭 함수의 각 호출은 각 타입 매개변수에 대한 자체 값 집합을 확인할 수 있다.

[예제]
makeTuple은 두 개의 타입 매개변수를 선언하고 입력된 값을 읽기 전용 튜플로 반환한다.

1
2
3
function makeTuple<First, Second>(first: First, second: Second) {
  return [first, second] as const;
}
  • 함수가 여러 개의 타입 매개변수를 선언하면 해당 함수에 대한 호출은 명시적으로 제네릭 타입을 모두 선언하지 않거나 모두 선언해야한다.
  • 타입스크립트는 아직 제네릭 호출 중 일부 타입만을 유추하지는 못한다.

🌿 제네릭 구조체에서 두 개보다 많은 타입 매개변수를 사용하지 말아야한다. 런타임 함수 매개변수처럼 많이 사용할수록 코드를 읽고 이해하는 것어 점점 어려워진다.


🌓 제네릭 인터페이스

  • 인터페이스도 제네릭으로 선언할 수 있다.
  • 인터페이스는 함수와 유사한 제네릭 규칙을 따르며 인터페이스 이름 뒤 <> 사이에 선언된 임의의 수의 타입 매개변수를 갖는다.
  • 해당 제네릭 타입은 나중에 멤버 타입과 같이 선언의 다른 곳에서 사용할 수 있다.

타입스크립트에서 내장 Array 메서드는 제네릭 인터페이스로 정의된다.

  • Array는 타입 매개변수 T를 사용해서 배열 안에 저장된 데이터 타입을 나타낸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface Array<T> {
  // ...
  /**
   * 배열에서 마지막 요소를 제거하고 그 요소를 반환
   * 배열이 비어있는 경우 undefined를 반환하고 베열은 수정되지 않음
   */

  pop(): T | undefined;

  /**
   * 배열의 끝에 새로운 요소를 추가하고 배열의 길이를 반환
   * @param items 배열에 추가된 새로운 요소
   */

  push(...items: T[]): number;

  // ...
}

유추된 제네릭 인터페이스 타입

  • 제네릭 함수와 마찬가지로 제네릭 인터페이스의 타입 인수는 사용법에서 유추할 수 있다.
  • 타입스크립트는 제네릭 타입을 취하는 것으로 선언된 위치에서 제공된 값의 타입에서 타입 인수를 유추한다.
  • 인터페이스가 타입 매개변수를 선언하는 경우, 해당 인터페이스를 참조하는 모든 타입 애너테이션은 이에 상응하는 타입 인수를 제공해야 한다.


🌓 제네릭 클래스

  • 인터페이스처럼 클래스도 나중에 멤버에서 사용할 임의의 수의 타입 매개변수를 선언할 수 있다.
  • 클래스의 각 인스턴스는 타입 매개변수로 각자 다른 타입 인수 집합을 가진다.

[예제]
Secret 클래스는 key와 Value 타입 매개변수를 선언한 다음 이를 멤버 속성, constructor 매개변수 타입, 메서드의 매개변수, 반환 타입으로 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Secret<Key, Value> {
  key: Key;
  value: Value;

  constructor(key: Key, value: Value) {
    this.key = key;
    this.value = value;
  }

  getValue(key: Key): Value | undefined {
    return this.key === key ? this.value : undefined;
  }
}

const storage = new Secret(12345, 'luggage'); // 타입: Secret<number, string>

storage.getValue(1987); // 타입: string | undefiend

제네릭 인터페이스와 마찬가지로 클래스를 사용하는 타입 애너테이션은 해당 클래스의 제네릭 타입이 무엇인지를 타입스크립트에 나타내야 한다.


1. 명시적 제네릭 클래스 타입

  • 제네릭 클래스 인스턴스화는 제네릭 함수를 호출하는 것과 동일한 타입 인수 유추 규칙을 따른다.
  • Secret(12345, 'luggage') 와 같이 함수 생성자에 전달된 매개변수의 타입으로부터 타입 인수를 유추할 수 있다면 타입스크립트는 유추된 타입을 사용한다.
  • 하지만 생성자에 전달된 인수에서 클래스 타입 인수를 유추할 수 없는 경우에는 타입 인수의 기본값은 unknown이 된다.

2. 제네릭 클래스 확장

  • 제네릭 클래스는 extends 키워드 다음에 오는 기본 클래스로 사용할 수 있다.
  • 타입스크립트는 사용법에서 기본 클래스에 대한 타입 인수를 유추하지 않는다.
  • 기본값이 없는 모든 타입 인수는 명시적 타입 애너테이션을 사용해 지정해야 한다.

3. 제네릭 인터페이스 구현

  • 제네릭 클래스는 모든 필요한 매개변수를 제공함으로써 제네릭 인터페이스를 구현한다.
  • 제네릭 인터페이스는 제네릭 기본 클래스를 확장하는 것과 유사하게 작동한다.
  • 기본 인터페이스의 모든 타입 매개변수는 클래스에 선언되어야 한다.

4. 메서드 제네릭

  • 클래스 메서드는 클래스 인스턴스와 별개로 자체 제네릭 타입을 선언할 수 있다.
  • 제네릭 클래스 메서드에 대한 각각의 호출은 각 타입 매개변수에 대해 다른 타입 인수를 갖는다.

[예제]

  • 제네릭 CreatePairFactory 클래스는 Key 타입을 선언하고 별도의 Value 제네릭 타입을 선언하는 createPair 메서드를 포함한다.
  • 그러면 createpair의 반환 타입은 {key: Key, value: Value}로 유추된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class CreatePairFactory<Key> {
  key: Key;

  constructor(key: Key) {
    this.key = key;
  }

  createPair<Value>(value: Value) {
    return { key: this.key, value };
  }
}

// 타입: CreatePairFactory<string>
const factory = new CreatePairFactory('role');

// 타입: { key: string, value: number }
const numberPair = factory.createPair(10);

// 타입: { key: string, value: string }
const stringPair = factory.createPair('Sophie');

5. 정적 클래스 제네릭

  • 클래스 정적 멤버는 인스턴스 멤버와 구별되고 클래스의 특정 인스턴스와 연결되어 있지 않다.
  • 클래스의 정적 멤버는 클래스 인스턴스에 접근할 수 없거나 타입 정보를 지정할 수 없다.
  • 따라서 정적 클래스 메서드는 자체 타입 매개변수를 선언할 수 있지만 클래스에 선언된 어떤 타입 매개변수에도 접근할 수 없다.


🌓 제네릭 타입 별칭

  • 타입 인수를 사용해 제네릭을 만드는 타입스크립트의 마지막 구조체는 타입 별칭이다.
  • 각 타입 별칭에는 T를 받는 Nullish 타입과 같은 임의의 수의 타입 매개변수가 주어진다.
1
type Nullish = T | null | undefined;

제네릭 타입 별칭은 일반적으로 제네릭 함수의 타입을 설명하는 함수와 함께 사용된다.

1
2
3
4
5
6
type CreatesValue<Input, Output> = (input: Input) => Output;

// 타입: (input: string) => number
let creator: CreatesValue<string, number>;

creator = (text) => text.length; // Ok

제네릭 판별된 유니언

  • 판별된 유니언 사용법 중 데이터의 성공적인 결과 또는 오류로 인한 실패를 나타내는 제네릭 ‘결과’ 타입을 만들기 위해 타입 인수를 추가하는 것이다.
  • 제네릭 타입과 판별된 타입을 함께 사용하면 재사용 가능한 타입을 모델링하는 훌륭한 방법을 제공할 수 있다.


🌓 제네릭 제한자

타입스크립트는 제네릭 타입 매개변수의 동작을 수정하는 구문도 제공한다.

제네릭 기본값

  • 제네릭 타입이 타입 애너테이션에 사용되거나 extends 또는 implements의 기본 클래스로 사용되는 경우 각 타입 매개변수에 대한 타입 인수를 제공해야 한다.
  • 타입 매개변수 선언 뒤에 =와 기본 타입을 배치해 타입 인수를 명시적으로 제공할 수 있다.
  • 기본값은 타입 인수가 명시적으로 선언되지 않고 유추할 수 없는 모든 후속 타입에 사용된다.
  • 타입 매개변수는 동일한 선언 안의 앞선 타입 매개변수를 기본값으로 가질 수 있다.
  • 각 타입 매개변수는 선언에 대한 새로운 타입을 도입하므로 해당 선언 이후의 타입 매개변수에 대한 기본값으로 이를 사용할 수 있다.
  • 모든 기본 타입 매개변수는 기본 함수 매개변수처럼 선언 목록의 제일 마지막에 와야 한다.
  • 기본값이 없는 제네릭 타입은 기본값이 있는 제네릭 타입 뒤에 오면 안된다.


🌓 제한된 제네릭 타입

  • 기본적으로 제네릭 타입에는 클래스, 인터페이스, 원싯값, 유니언, 별칭 등 모든 타입을 제공할 수 있다.
  • 그러나 일부 함수는 제한된 타입에서만 작동해야 한다.
  • 타입스크립트는 타입 매개변수가 타입을 확장해야 한다고 선언할 수 있으며 별칭 타입에만 허용되는 작업이다.
  • 타입 매개변수를 제한하는 구문은 매개변수 이름 뒤에 extends 키워드를 배치하고 그 뒤에 이를 제한할 타입을 배치한다.

keyof와 제한된 타입 매개변수

  • extendskeyof를 함께 사용하면 타입 매개변수를 이전 타입 매개변수의 키로 제한할 수 있다.
  • 또한 제네릭 타입의 키를 지정할 수 있는 유일한 방법이기도 하다.
  • keyof가 없었다면 제네릭 key 매개변수를 올바르게 입력할 방법이 없었을 것이다.
1
2
3
4
5
6
7
8
9
10
function get<T>(container: T, key: keyof T) {
  return container[key];
}

const roles = {
  favorite: 'Fargo',
  others: ['Almost Famous', 'Burn After Reading', 'Nomadland'],
};

const found = get(roles, 'favorite'); // 타입: string | string[]

제네릭 함수를 작성할 때 매개변수의 타입이 이전 매개변수 타입에 따라 달라지는 경우를 알고 있어야 하고, 이러한 경우 올바른 매개변수 타입을 위해 제한된 타입 매개변수를 자주 사용하게 된다.


🌓 Promise

  • 자바스크립트의 Promise는 네트워크 요청과 같이 요청 이후 결과를 받기까지 대기가 필요한 것을 나타낸다.
  • 각 Promise는 대기 중인 작업이 resolve(성공적으로 완료됨) 또는 reject(오류 발생)하는 경우 콜백을 등록하기 위한 메서드를 제공한다.
  • 임의의 값 타입에 대해 유사한 작업을 나타내는 Promise의 기능은 타입스크립트의 제네릭과 자연스럽게 융합된다.
  • Promise는 타입스크립트 타입 시스템에서 최종적으로 resolve된 값을 나타내는 단일 타입 매개변수를 가진 Promise 클래스로 표현된다.

1. Promise 생성

  • 타입스크립트에서 Promise 생성자는 단일 매개변수를 받도록 작성된다.
  • 해당 매개변수의 타입은 제네릭 Promise 클래스에 선언된 타입 매개변수에 의존한다.

축소된 형식은 다음과 같다.

[예제]

1
2
3
4
5
6
7
8
9
10
class PromiseLike<Value> {
  constructor(
    executor: (
      resolve: (value: Value) => void,
      reject: (reason: unknown) => void
    ) => void
  ) {
    /* ... */
  }
}
  • 결과적으로 값을 resolve하려는 Promise를 만들려면 Promise의 타입 인수를 명시적으로 선언해야 한다.
  • 타입스크립트는 명시적 제네릭 타입 인수가 없다면 기본적으로 매개변수 타입을 unknown으로 가정한다.
  • Promise 생성자에 타입 인수를 명시적으로 제공하면 타입스크립트가 결과로서 생기는 Promise 인스턴스의 resolve된 타입을 이해할 수 있다.
1
2
3
4
5
6
7
8
9
// 타입: Promise<unknown>
const resolvesUnknown = new Promise((resolve) => {
  setTimeout(() => resolve('Done!'), 1000);
});

// 타입: Promise<string>
const resolvesString = new Promise<string>((resolve) => {
  setTimeout(() => resolve('Done!'), 1000);
});


2. async 함수

  • 자바스크립트에서 async 키워드를 사용해 선언한 모든 함수는 Promise를 반환한다.
  • 자바스크립트에서 async 함수에 따라서 반환된 값이 Thenable(.then 메서드가 있는 객체, 실제로는 거의 항상 Promise)이 아닌 경우, Promise.resolve가 호출된 것처럼 Promise로 래핑된다.

[예제]

  • lengthAfterSecond는 Promise를 직접적으로 반환하는 반면 lengthImmediately는 async 함수이고, 직접 number를 반환하기 때문에 Promise를 반환하는 것으로 간주된다.
1
2
3
4
5
6
7
8
9
10
11
// (text: string) => Promise<number>
async function lengthAfterSecond(text: string) {
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return text.length;
}

// (text: string) => Promise<number>
async function lengthImmediately(text: string) {
  return text.length;
}


🌓 제네릭 올바르게 사용하기

타입스크립트의 모범 사례는 필요할 때만 제네릭을 사용하고, 제네릭을 사용할 때는 무엇을 위해 사용하는지 명확해야 한다.

1. 제네릭 황금률

  • 함수에 타입 매개변수가 필요한지 여부를 판단할 수 있는 간단하고 빠른 방법은 타입 매개변수가 최소 두 번 이상 사용되었는지 확인하는 것이다.
  • 제네릭은 타입 간의 관계를 설명하므로 제네릭 타입 매개변수가 한 곳에만 나타나면 여러 타입 간의 관계를 정의할 수 없다.
  • 따라서 각 함수 타입 매개변수는 매개변수에 사용되어야 하고, 그다음 적어도 하나의 다른 매개변수 또는 함수의 반환 타입에서도 사용되어야 한다.

[예제]
logInput 함수는 input 매개변수를 선언하기 위해 Input 타입 매개변수를 정확히 한 번 사용한다.

1
2
3
function logInput<Input extends string>(input: Input) {
  console.log('Hi!', input);
}

2. 제네릭 명명 규칙

  • 타입스크립트를 포함한 많은 언어가 지키는 타입 매개변수에 대한 표준 명명 규칙은 기본적으로 첫 번째 타입 인수로 T를 사용하고, 후속 타입 매개변수가 존재하면 U, V 등을 사용하는 것이다.
  • 타입 인수가 어떻게 사용되어야 하는지 맥락과 관련된 정보가 알려진 경우 명명 규칙은 경우에 따라 해당 용어의 첫 글자를 사용하는 것으로 확장된다.
  • 예를 들어 상태 관리 라이브러리에서는 제네릭 상탤르 S로, 데이터 구조의 키와 값은 KV로 나타내기도 한다.


Reference

러닝 타입스크립트 (Learning TypeScript)

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

[Error] Warning: Received true for a non-boolean attribute 에러 해결

[JS] 자바스크립트는 싱글스레드인데 어떻게 멀티스레드처럼 동작할 수 있을까?