본문으로 바로가기

제네릭을 통해 다양한 곳에서 활용될 수 있는 함수 정의

특정 타입이 아닌 제네릭을 잘 활용하면 type-safe하면서도 활용도가 높은 함수를 정의할 수 있습니다.

function getFisrtElem<T> (arr: T[]) {
  return arr[0]
}

getFisrtElem([1, 2, 3]) // number로 inferred됨
getFisrtElem(['h', 'l']) // string으로 inferred됨

 

Constraints (타입 제약)

제네릭을 활용하면서도 동시에 특정한 메서드나 프로퍼티가 있는 값만 사용하고 싶을 때가 있을 것입니다. 

아래의 경우에는 T라는 제네릭에 lenght라는 프로퍼티가 존재하는지 알 수 없기 때문에 경고를 뱉습니다.

function returnLen<T> (a: T) {
  return a.length // Property 'length' does not exist on type 'T'.
}

 

따라서 아래처럼 Generic을 extends해주어서 특정 프로퍼티를 가진 값을 generic으로 만들 수 있습니다.

// length 라는 number형의 프로퍼티를 가진 값만 인자로 받겠음.
function returnLen<T extends { length: number }> (a: T) {
  return a.length
}

returnLen([2, 3])
returnLen({}) // error

 

 

타입 제약 generic function에서 실수하기 쉬운 것

 

간단해보이지만, 아래와 같은 경우에는 에러가 발생되게 된다.

function minimumLength<T extends { length: number }> (obj: T, minimum: number): T {
  if (obj.length >= minimum) {
    return obj
  } else {
    return { length: minimum }  // Type '{ length: number; }' is not assignable to type 'T'.
  }
}

 

에러 메세지부터 번역해보면 다음과 같다.

 

Type '{ length: number; }' is not assignable to type 'T'. '{ length: number; }' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{ length: number; }'.

 

쉽게 말해서 '{ length: number; }'는 T에 할당될 수 없다는 것이다. '{ length: number; }' 가 constraint된 T의 조건이긴 하지만

T는 '{ length: number; }'를 가진 또 다른 인스턴스일 수 있기 때문이다.

 

이게 도대체 무슨 소리인가? 예시로 들어 설명해보자.


minimumLength([1], 2)의 경우 {length: 2}가 반환되게 된다.
그런데 그렇게 될 경우 T는 배열(number[])인데, 실제로 반환되는 값은 number[]가 아닌 object가 반환되게 된다.

 

그래서 '{ length: number; }'를 지닌 값이 인자로 들어올 수 있지만 그것은 단순히 '{ length: number; }'를 가진 인자가 아니라 extends된 타입이다. 따라서 T는 '{ length: number; }'를 포함한 다른 subtype일 수 있는 것이다. 예를 들면 T는 length를 가진  string, array 등이 될 수 있다. 그러므로 단순히 '{ length: number; }'를 가진 객체를 반환할 수는 없다. 

 

결론적으로는 반환값의 타입인 T가 아니기 때문에 타입 에러를 발생시킨다. 따라서 우리는 constraint된 generic에 대한 멘탈 모델은 "뭐든지 다 되는데 제약한 것"이 아니라 "들어온 인자에 대한 타입을 할당하되 조건을 걸어 제약한 것"으로 생각해야 한다.

 

 

Generic을 사용한 함수의 추론 

 

1. 특정 타입이 다른 타입과 연관되지 않는다면 굳이 Generic을 사용하지 말자.

 

아래의 예시의 경우 generic을 굳이 사용할 필요가 없는데도 사용한 케이스다. 물론 추론은 잘 된다.

generic은 무조건 '관계'가 설정되었을 때만 사용하는 것이 좋다.

그러니까, generic 타입이 다른 인자나 반환 값의 타입에 연관되어 있는 경우에만 generic을 사용하는 것이 좋다.

// verbose
function greetFn1<T extends string> (s: T) {
  return 'Hello, ' + s
}

// good
function greetFn2 (s: string) {
  return 'Hello, ' + s
}

 

 

2. 그냥 type을 사용할 수 있다면 굳이 constraints된 generic을 사용하지 말자.

 

constraints된 type은 위에서 살펴본 것처럼 제약 조건에서부터 존재할 수 있는 subtype의 다양성 때문에 작성자 스스로가 예측하지 못한 케이스가 종종 발생할 수 있다.

게다가 아래와 같은 케이스를 살펴보면, any[]를 확장한 Type은 내부에 무엇이 들었는지 알 수 없기 때문에 명백히 숫자 배열을 전달했음에도 any로 추론하고 있다.

function getFirstElem1<Type> (arr: Type[]) {
  return arr[0]
}

function getFirstElem2<Type extends any[]> (arr: Type) {
  return arr[0]
}

const a = getFirstElem1([1, 2, 3]) // 반환 값 타입이 number임 (good)
const b = getFirstElem2([1, 2, 3]) // 반환 값 타입 추론이 any임 (bad)

 


darren, dev blog
블로그 이미지 DarrenKwonDev 님의 블로그
VISITOR 오늘 / 전체