TypeScript 3 : Generic

백은진·2020년 11월 29일
2

TIL (Today I Learned)

목록 보기
55/106

Generic

소프트웨어 엔지니어링에서 '재사용이 가능한 컴포넌트를 구축하는 것'은 중심이 되는 부분이다. 제네릭은 재사용이 가능한 컴포넌트를 생성하는 도구상자의 주요 도구 중 하나이다. 제네릭을 이용하면 단일 타입이 아닌 다양한 타입에서 작동하는 컴포넌트를 작성할 수 있다.


제네릭의 Hello World (Hello World of Generics)

제네릭의 identity 함수를 작성해보자. identity 함수는 인수로 무엇이 오던 그대로 반환하는 함수이다.

Case 1) 제네릭이 없다면, identity 함수에 특정 타입이나 any 타입을 지정해줘야 한다.

function identity(arg: number): number {
    return arg;
}

or

function identity(arg: any): any {
    return arg;
}

any를 쓰는 것은 함수의 arg가 어떤 타입이든 받을 수 있다는 점에서 제네릭이지만, 실제로 함수가 이를 반환할 때 어떤 타입인지에 대한 정보는 잃게 된다.

T라는 '타입 변수'를 사용하여 인수의 타입을 캡쳐하고, 무엇이 반환되는지 표시해보자.

function identity<T>(arg: T): T {
    return arg;
}

T는 유저가 준 인수의 타입(예를 들어 number)을 캡쳐하고, 이 정보를 나중에 사용할 수 있게 한다.

위의 예에서는 T를 반환 타입으로 다시 사용한다. 즉, 인수와 반환 타입이 같은 타입을 사용하고 있다. 이는 타입 정보가 함수의 한 쪽에서 다른 한 쪽으로 이동하게끔 한다.

제네릭 Identity 함수를 작성하고 나면, 두 가지 방법 중 하나로 호출할 수 있다.

첫 번째 방법: 함수에 타입 인수를 포함한 모든 인수를 전달하기.

let output = identity<string>("myString"); // 출력 타입은 'string'입니다.

함수를 호출할 때의 인수 중 하나로써 T를 string으로 명시하고, 인수 주변에 소괄호 () 대신 꺽쇠 <>로 감싸주었다.

두 번재 방법: 타입 인수 추론을 사용하기.

타입 인수 추론을 사용하는 것이 가장 일반적인 방법이다. 즉, 전달하는 인수에 따라서 컴파일러가 T의 값을 자동으로 정하게 한다.

let output = identity("myString"); //출력 타입은 'string'입니다.

컴파일러는 값인 "myString"를 보고, 그것의 타입으로 T를 정한다.

인수 추론은 코드를 간결하고 가독성 있게 하지만, 컴파일러가 타입을 유추할 수 없는 더 복잡한 예제에서는 명시적인 타입 인수 전달이 필요할 수 있다.


제네릭 타입 변수 작업 (Working with Generic Type Variables)

identity와 같은 제네릭 함수를 만들 때, 컴파일러가 함수 본문에 제네릭 타입화된 매개변수를 쓰도록 강요한다. 따라서, 이 매개변수들은 실제로 any나 모든 타입이 될 수 있는 것처럼 취급할 수 있게 된다.

function loggingIdentity<T>(arg: T): T {
  console.log(arg.length); // 오류: T에는 .length 가 없다.
  return arg;
}

컴파일러는 arg의 멤버 .length를 사용하고 있다는 오류를 내지만, 어떤 곳에서도 arg에 이 멤버가 있다는 것이 명시되어 있지 않다. 따라서, 위의 함수를 쓰고 있는 사용자는 .length 멤버가 없는 number를 대신 전달할 수도 있다.

위의 함수가 T가 아닌 T의 배열에서 동작하도록 의도했다고 해보자. 배열의 경우에서는 .length 멤버를 사용할 수 있다.

function loggingIdentity<T>(arg: T[]): T[] {
  console.log(arg.length); // 배열은 .length를 가지고 있다. 따라서 오류가 발생하지 않는다. 
  return arg;
}

loggingIdentity의 타입을 "제네릭 함수 loggingIdentity는 타입 매개변수 T와 T 배열인 인수 arg를 취하고, T 배열을 반환한다"라고 읽을 수 있다.
만약 인자로 number 배열을 넘기면 T가 number에 바인딩 되므로, 함수는 number 배열을 얻게 된다. 이렇게 전체 타입변수를 쓰는 것보다 하나의 타입으로써 제네릭 타입변수 T를 사용하는 것은 굉장한 유연함을 제공한다.

위 예제는 아래 예제와 동일하다.

function loggingIdentity<T>(arg: Array<T>): Array<T> {
  console.log(arg.length); // 배열은 .length를 가지고 있습니다. 따라서 오류는 없습니다.
  return arg;
}

제네릭 타입 (Generic Types)

어떻게 Array<T>와 같은 고유한 제네릭 타입을 만들 수 있는지, 함수 자체의 타입과 제네릭 인터페이스를 만드는 방법은 무엇인지에 대해 알아보자.

제네릭 함수의 타입은 함수 선언과 유사하게 타입 매개변수가 먼저 나열되는 '비-제네릭 함수 타입'과 비슷하다.

// 제네릭 함수 타입
function identity<T>(arg: T): T {
  return arg;
}

// 비-제네릭 함수 타입
let myIdentity: <T>(arg: T) => T = identity;

타입 변수의 수와, 타입 변수가 사용되는 방식에 따라 매개변수에 다른 이름을 사용할 수도 있다.

function identity<T>(arg: T): T {
  return arg;
}

let myIdentity: <U>(arg: U) => U = identity;

제네릭 타입을 객체 리터럴 타입의 함수 호출 시그니처로 작성할 수도 있다.

function identity<T>(arg: T): T {
  return arg;
}

let myIdentity: { <T>(arg: T): T } = identity;

첫 번째 제네릭 인터페이스와 같이 객체 리터럴을 인터페이스로 만들어 두고, 이를 사용할 수도 있다.

interface GenericIdentityFn {
  <T>(arg: T): T;
}

function identity<T>(arg: T): T {
  return arg;
}

let myIdentity: GenericIdentityFn = identity;

제네릭 매개변수를 전체 인터페이스의 매개변수로 옮기고, 이를 통해 제네릭 타입을 확인할 수 있다.
(예를 들어 Dictionary<string>라고 작성하면, 인터페이스의 다른 모든 멤버가 타입 매개변수를 볼 수 있다.)

interface GenericIdentityFn<T> {
  (arg: T): T;
}

function identity<T>(arg: T): T {
  return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

위의 예제는 제네릭 함수를 작성하는 것 대신, 제네릭 타입의 일부인 비-제네릭 함수 시그니처를 가진다.

GenericIdentityFn 함수를 사용할 때, 시그니처가 사용할 것을 효과적으로 제한할 특정한 타입 인수(number같은)가 필요하다.
타입 매개변수를 호출 시그니처에 바로 넣을 때와 인터페이스 자체에 넣는다는 차이가 있다.


제네릭 제약조건 (Generic Constraints)

특정 타입들로만 동작하는 제네릭 함수를 만들고 싶을 때 사용한다.
예를 들어 앞의 예제들 중 loggingIdentity 함수에서 arg의 프로퍼티 .length에 접근하기를 원했지만, 컴파일러는 모든 타입에서 .length 프로퍼티를 가지는 것을 증명할 수 없으니 에러를 발생시켰다.

function loggingIdentity<T>(arg: T): T {
    console.log(arg.length);  // 오류: T에는 .length가 없다.
    return arg;
}

'any와 모든 타입에서 동작'하는 대신에, '.length 프로퍼티가 있는 any와 모든 타입들에서 동작'하는 것으로 제한을 하고 싶다. 이를 위해 T가 무엇이 될 수 있는지에 대한 제약 조건을 나열해야 한다.

이 제약조건이 명시하는 인터페이스를 생성해보자.

// .length를 가진 인터페이스를 생성한다. 
interface Lengthwise {
    length: number;
}

// extends 키워드로 표현한 인터페이스를 이용하여 제약사항을 명시한다.
function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);  // 이제 .length 프로퍼티가 있는 것을 알기 때문에 더 이상 오류가 발생하지 않습니다.
    return arg;
}

제네릭 함수는 이제 제한되어 있기 때문에, 모든 타입에 대해서는 동작하지 않는다. 대신 필요한 프로퍼티들이 있는 타입의 값을 전달해야 한다.

loggingIdentity(3);  // 오류, number는 .length 프로퍼티가 없다.
loggingIdentity({length: 10, value: 3});

제네릭 제약조건에서 타입 매개변수 사용 (Using Type Parameters in Generic Constraints)

다른 타입 매개변수로 제한된 타입 매개변수를 선언할 수 있다. 이름이 있는 객체에서 프로퍼티를 가져오고 싶은 경우를 예로 들어 보자.
아래의 예제는 실수로 obj에 존재하지 않는 프로퍼티를 가져오지 않도록 하기 위해 두 가지 타입에 제약조건을 두었다.

function getProperty<T, K extends keyof T>(obj: T, key: K) {
    return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a"); // 성공
getProperty(x, "m"); // 오류: 인수의 타입 'm' 은 'a' | 'b' | 'c' | 'd'에 해당되지 않음.
profile
💡 Software Engineer - F.E

0개의 댓글