TypeScript - Generics

lbr·2022년 8월 29일
0

Generics와 Any의 다른 점

실습을 위한 설정

npm init -y
npm i typescript -D
npx tsc --init

실습

any의 문제점

function helloString(message: string): string {
  return message;
}

function helloNumber(message: number): number {
  return message;
}

// 더 많은 반복된 함수들...
// 그래서 보통 이런 문제를 해결하기 위해서
// 모든 타입을 받을 수 있게 설정하고,
// 모든 타입을 리턴할 수 있게 설정할 수 있습니다.

// 모든타입에 쓰이는 것이 사실 any 입니다.
// 하지만 우리가 의도한 것과 다르게 동작합니다.
function hello(message: any): any {
  return message;
}

// typescript는 any로 추론하여 any에 length를 사용하는 것처럼 되어버립니다.
console.log(hello('Mark').length); 

// 마찬가지로 number타입에는 length를 사용할 수 없음에도 any 타입이기에 에러가 나지 않습니다.
console.log(hello(39).length);

// 컴파일 타임에는 문제가 있다고 나오지 않지만, 런타임 때에는 문제가 발생합니다.

Generics 사용

이제 Generics를 사용해보겠습니다.

// 인수로 들어오는 타입에 맞춰서 T 라는 이름으로 타입이 지정됩니다.
// 해당 함수 안에서 T 라는 이름으로 지정된 타입을 사용할 수 있습니다.
function helloGeneric<T>(message: T): T {
  return message;
}

// 문자열을 리터럴로 넣었기 때문에 리터럴 타입으로 추론이되서,
// T 가 문자열이 아니고 Mark라고하는 값의 리터럴 타입으로 지정되었습니다.
console.log(helloGeneric('Mark')); 

"Mark" 리터럴 타입으로 지정되었습니다.

console.log(helloGeneric('Mark').length); 

이전과는 다르게 any가 아닌 string 타입으로 정상적으로 추론되었습니다.
"Mark"가 string 안에 포함되어있기 때문에 string의 length를 이용해서 타입이 추론됩니다.

숫자를 넣고 아까처럼 length를 사용하려 한다면..

에러가 발생하고, 자동완성에도 .length 는 나오지 않습니다.

이번에는 true 를 넣어보겠습니다.

console.log(helloGeneric(true)); // true라고하는 리터럴 타입으로 추론됩니다.

true라고하는 리터럴 타입으로 추론됩니다.

위 함수에서 지정한 T라는 이름을 변수처럼 함수안에서 사용할 수 있습니다.

Generics Basic

T는 제네릭을 선언할 때 관용적으로 사용되는 식별자로 타입 파라미터(Type parameter)라 합니다.

T는 Type의 약자로 다른 언어에서도 제네릭을 선언할 때 관용적으로 많이 사용된다. 이 부분에는 식별자로 사용할 수 있는 것이라면 무엇이든 들어갈 수 있다. 이를테면 $_도 가능하다는 의미다. 하지만 대개의 경우 T를 사용한다. 여기에서 T를 타입 변수(Type variables)라고 한다.

function helloBasic<T, U, K> {

}

타입 파라미터는 여러 개를 사용할 수 있습니다.

function helloBasic<T>(message: T): T {
  return message;
}

// 사용하는 방법은 2가지입니다.

// 1. 사용할 때에도 타입 파라미터를 지정해줍니다.
helloBasic<string>(39); // 에러
// T 에 string을 지정했기 때문에 위에서 매개변수를 T 타입으로 받고 있으므로 매개변수도 string 타입으로 제한을 합니다.
// 2. 제네릭기호 없이 직접 호출합니다.
// 인수로 들어간 값에 의해서 T가 추론됩니다.
// number 타입이 추론되어야 하는 데 36이라는 값을 가진 숫자리터럴 타입으로 추론됩니다. 
// 우리 기준에서는 T 가 number가 되어야 하는데, typescript 입장에서는 가장 좁은 범위의 type을 추론하기 때문에
// 36을 넣게 되면 number가 아닌 36 타입으로 추론됩니다.
helloBasic(36);

사용하는 방법은 2가지입니다.

function helloBasic<T, U>(message: T, comment: U ): T {
  return message;
}

helloBasic<string, number>(39); // 에러: 제네릭을 사용하여 호출했으므로 제한이 걸려서 string 타입의 첫번째 맴개변수에 값이 없다고 에러가 발생합니다.
helloBasic<string, number>("Mark", 39); // 정상실행

helloBasic(36, 39); // T는 36, U는 39 타입으로 추론됩니다.

여러개의 타입을 사용하는 경우의 실행방법.

Generics Array & Tuple

Generic을 이용하여 함수 안에서 ArrayTuple 을 표현하는 방법에 대해서 배워보겠습니다.

function helloArray<T>(message: T[]): T {
  return message[0];
}

// 두 요소의 공통 타입인 string으로 추론됩니다.
helloArray(["Hello", "world"]); // 리턴 : "Hello"

두 요소의 공통 타입인 string으로 추론됩니다.

// T 가 string | number 인 유니온 타입이 됩니다.
helloArray(["Hello", 5]); // 리턴 : "Hello"

string과 숫자의 조합으로 되어 있어서 T 가 string | number 인 유니온 타입이 됩니다.

string과 number의 공통으로 사용할 수 있는 함수만 자동완성 메뉴에 나타납니다.

// message가 튜플(Tuple)형태로 처리됩니다.
// index 0 번째가 리턴 되므로 무조건 T 타입이 리턴됩니다. 그래서 리턴타입으로 T 만을 지정할 수 있습니다.
function helloTuple<T, K>(message: [T, K]):T  {
  return message[0];
}

helloTuple(["Hello", "world"]);
helloTuple(["Hello", 5]);

튜플형태의 사용.

Generics Function

지금까지 우리는 Generics 을 함수를 직접 구현하면서 사용했지만,
이번에는 함수의 타입만 선언하는 방식에 대해서 배워보도록 하겠습니다.

// 타입 알리아스로 만들어 보겠습니다.
type HelloFunctionGeneric1 = <T>(message: T) => T;

// 함수를 구현합니다.
const HelloFunction1: HelloFunctionGeneric1 = <T>(message: T): T => {
  return message;
};


// 인터페이스로 함수를 표현하겠습니다.
interface HelloFunctionGeneric2 {
  <T>(message: T): T;
}

// 함수를 구현합니다.
const helloFunction2: HelloFunctionGeneric2 = <T>(message: T): T => {
  return message;
};

기존의 함수 앞에 <T> 만 표현해주면 됩니다.

Generics Class

class에 Generic을 선언하고 사용하는 방법에 대해서 배워보겠습니다.

class Person<T> {
  private _name: T;

  constructor(name: T) {
    this._name = name;
  }
}
// 이 클래스 안에서 T를 변수처럼 사용할 수 있습니다.

new Person("Mark");

// 제네릭을 먼저 string으로 세팅하면
// constructor에는 반드시 string이 들어가야하는 제한이 생깁니다.
// 이런 동작은 이전에 배운 함수때와 동일합니다.
// new Person<string>(39); // 에러
new Person<string>("Mark"); // 정상동작

이번에는 제네릭을 2개 지정해보겠습니다.

class Person<T, K> {
  private _name: T;
  private _age: K;

  constructor(name: T, age: K) {
    this._name = name;
    this._age = age;
  }
}

new Person("Mark", 39);
new Person<string, number>("Mark", 39);
// new Person<string, number>("Mark", "age"); // 에러

이런식으로 컴파일 타임에 에러를 미리 체크할 수 있는 역할을 하기 때문에 굉장히 유용합니다.

Generics with extends

Generic하고 extends 키워드를 함께 사용해보겠습니다.

extends은 기존에 class에서 배웠던대로 상속의 의미였습니다. 하지만 Generic 으로 사용할 때에는 상속이라는 의미보다는 다른 특별한 의미로 사용됩니다.

extends 를 상속이라는 느낌으로 사용을 하게되면, 뭔가 맞지 않는다는 느낌이 듭니다.

class PersonExtends<T extends string | number> {
  private _name: T;

  constructor(name: T) {
    this._name = name;
  }
}

extends 키워드를 이용해서 타입을 더 추가해 줄 수 있습니다.

일단 <T extends string | number> 여기에서 extends를 기존의 상속이라는 개념으로 생각해본다면, T는 string | number 유니온 타입을 상속 받고 있습니다. 그럼 string | number 도 되면서 동시에 T 타입도 가질 수 있다고 생각할 수도 있는데,

그렇지 않습니다.
현재 T는 string 과 number만 가능하다는 의미입니다.

class PersonExtends<T extends string | number> {
  private _name: T;

  constructor(name: T) {
    this._name = name;
  }
}

new PersonExtends('Mark'); // 가능
new PersonExtends(39); // 가능
// new PersonExtends(true); // 에러

stringnumber 타입 이외에 다른 타입은 넣을 수 없습니다.

정리

class PersonExtends<T extends string | number>

string과 number로 제한하고 있다고 쉽게 생각할 수 있습니다.
하지만, 코드가 복잡해지고, 본인이나 다른사람들이 사용하려고 할 때, 이런 제약을 걸어주지 않으면 예상하지 못한 에러가 날 수도 있습니다.

그래서 타입은 항상 가장 작은 범위로 제한을 해주는 것이 좋습니다.
그냥 <T> 였다면, 사용자 입장에서 "그냥 <T> 이니까 아무거나 넣어도 되겠구나" 라고 생각할 수도 있습니다. 하지만 위처럼 제한을 해준다면 훨씬 더 가이드가 잘 됩니다.

keyof & type lookup system

keyof 키워드와 generic 을 이용해서 타입을 적절히 찾아내고 활용할 수 있는 시스템을 만들어 보겠습니다.

시스템이라고 하면 거창해 보이지만 그냥 컴파일 타임에 타입을 정확하게 찾아낼 수 있는 방식이라고 생각하면 됩니다.

interface IPerson {
  name: string;
  age: number;
}

const person: IPerson = {
  name: 'Mark',
  age: 39
}

// 에러는 나오지 않지만, 아래 함수는 잘못되었습니다.
// key가 "name" 이면 string을 넣을 수 있고,
// "age" 이면 number를 넣을 수 있게 타입처리를 해주어야합니다.
// 현재 아래 함수는 key가 어떤 값이 들어오든 전부 string | number 유니온
// 타입으로 값을 지정할 수 있는 상태입니다.
function getProp(obj: IPerson, key: 'name' | 'age'): string | number {
  return obj[key];
}

// value에는 string | number 이 올수 있는 것이 아니라
// key가 "name" 이면 string을 넣을 수 있고,
// "age" 이면 number를 넣을 수 있게 타입처리를 해주어야합니다.
// 아래 함수는 잘못되었습니다.
function setProp(obj: IPerson, key: "name" | "age", value: string | number): void {
  obj[key] = value; // 에러
}

현재 key: "name" | "age"value: string | number 는 관계성을 가지고 있습니다.

여기서 generic과는 별개로 keyof 라는 키워드에 대해서 배워보겠습니다.

keyof 키워드는 타입 앞에 붙여서 새로운 타입을 만들어 냅니다.
keyof 가 무엇인지 확인해 보기 위해 아래처럼 작성해봅니다.

interface IPerson {
  name: string;
  age: number;
}

type Keys = keyof IPerson;
const keys: Keys = "age" // age와 name만이 자동완성으로 나옵니다.

객체에 'keyof'를 붙이면 결과물이 타입으로 나옵니다.
그 타입은 그 객체 안의 키의 이름들로 된 문자열 유니온 타입을 반환합니다.
예 : ("name" | "age")

그럼, 타입이 들어갈 자리에 "name" | "age" 대신에 keyof IPerson 을 넣을 수 있습니다.

function getProp(obj: IPerson, key: keyof IPerson): IPerson[keyof IPerson] {
  return obj[key];
}

function setProp(
   obj: IPerson,
   key: keyof IPerson,
   value: string | number
 ): void {
   obj[key] = value;
}

IPerson[keyof IPerson] 의 결과물은
=> IPerson["name" | "age"] 이것은 또
=> IPerson["name"] | IPerson["age"] 이렇게 유니온 타입으로 나옵니다.
=> string | number

여기까지는 아직 우리가 원하는 것을 얻지 못했습니다.
우리는 getProp에 name 을 넣으면 타입으로 알아서 string이 지정되기를 원합니다.

IPersonkeyof IPerson 을 이용해서 그 관계성을 특정한 타입으로 지정해 주어야합니다.
여기에서 generic이 사용이 됩니다.

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

getProp(person, 'name'); // 잘못된 속성이름을 넣게 되면 에러가 나면서 가이드를 해줍니다.

K extends keyof T 를 이용하여 해당 객체의 속성이름을 가이드해주고,
T[K] 를 이용하여 반환되는 값이 해당 속성값의 타입으로 지정되게합니다.

다음은 setProp을 바꿔보겠습니다.

먼저 T를 설정하고,

function setProp(
  obj: T,
  key: keyof T,
  value: T[keyof T]
): void {
  obj[key] = value;
}

그 다음 K를 설정하고, extends를 이용해서 T와 관계를 맺어줍니다.

function setProp<T, K extends keyof T>(
  obj: T,
  key: K,
  value: T[K]
): void {
  obj[key] = value;
}

setProp(person, "name", "Anna");

이렇게 하면 이제 유니온 타입이 아니기 때문에 setProp에서 obj[key] 에 에러가 나던 것이 더 이상 에러가 나지 않습니다.

정리

keyof 키워드와 generic, extends 를 이용해서 TK 의 관계를 규명하는 방식으로 generic 를 활용하면 타이핑이 좀 더 안전하게 되었습니다.
이렇게 작성해주면, 런타임 가기 전에 컴파일 타임에 타이핑을 이용해서 버그나 오류를 발견해 낼 수 있습니다.

정리

지금까지 generic에 대해서 알아봤습니다.
generic 를 이용하면 나중에 조건부 타이핑을 이용해서 좀 더 심화된 작업까지 할 수 있습니다.

0개의 댓글