Typescript의 제네릭을 헬퍼타입을 만들어보며 알아보기

2022. 7. 21

제네릭의 필요성

제네릭이란 개념은 기본적으로 타입이 있는 언어를 사용하는 개발자들의 경우에는 익숙할 것이다. (Java, C 계열 등)

하지만 이전에 javascript를 사용해서 개발을 했던 개발자라면 typescript를 사용하면서부터 타입을 기재하는 것에 대한 개념을 활용하며 개발하고 있을 것이다.

보통 간단한 타입의 경우에는 사용하는 것이 크게 어렵지는 않다.

이를테면 변수 선언시 타입을 명시한다던지, 함수의 인자에 특정 타입을 명시하는 등의 방식을 통해서 처리하는 방법이 있다.

let count: number = 0

function returnData(data: number): number {
  return data
}

returnData(1) // 1
returnData('1') // Argument of type 'string' is not assignable to parameter of type 'number'.

하지만 원하는 값이 항상 정해진 타입이 아니라면 어떻게 될까? 🤔

예를들어, 위 returnData 함수의 경우도 숫자를 반환할 수도 있지만 문자열을 반환해 주고 싶을 수도 있다.

이런 고민을 해결해주는 친구가 바로 제네릭(Generic)이다.

제네릭은 구체적인 타입을 직접 명시하지 않고 다양한 타입에 대해서 대응할 수 있도록 해주며 이를 통해 더욱 확장성 있고 재사용 가능하도록 도와준다.

처음 제네릭을 접하게 되면 어렵고 활용하기가 굉장히 어려운데, 이를 조금이라도 해소할 수 있도록 예시를 통해 어떤 느낌으로 사용되는지 알아보자

제네릭의 사용법

먼저 이전에 있던 returnData 함수를 제네릭을 사용해 다시 만들어보자

function returnData<T>(data: T): T {
  return data
}

returnData<number>(1) // function returnData<number>(data: number): number
returnData(1) // function returnData<1>(data: 1): 1

returnData<string>('1') // function returnData<string>(data: string): string
returnData('1') // function returnData<"1">(data: "1"): "1"

위 코드에서 함수명에 <T>를 통해서 제네릭 타입에 대해 명시하고 이걸 인자 타입과 반환 타입에 명시해주면

개발자가 명시한 타입으로 타입이 동적으로 작동하게 된다.

이렇게 되면 이전에 있던 예시와는 다르게 원하는 값에 대한 타입에 대한 자유도가 생기기 때문에 어떤 타입의 값이든 반환할 수 있는 함수가 되었다. 🎉

그런데 여기서 한가지 의문이 드는 부분이 있다.

제네릭을 통해서 자유분방함을 준건 오케인데.. 그러면 어느정도 제약을 두려면 어떻게 해야하지..? 🤔

그럴 때 사용하는 것이 바로 extends 키워드다.

extends로 제네릭 타입 상속하기

방금 만든 returnData 함수의 경우 모든 타입에 대해서 사용 가능하다.

그래서 이를 extends 키워드를 사용해 숫자와 문자열 두가지 타입에 대해서만 동작하도록 처리해보자

function returnData<T extends number | string>(data: T): T {
  return data
}

returnData(1)
returnData('1')
returnData(true) // Argument of type 'boolean' is not assignable to parameter of type 'string | number'.

제네릭에 extends 키워드를 통해 특정 타입에 대해서만 동작하도록 명시했다.

extends는 상속할 때 자주 사용하는데 비슷한 맥락으로 해석하자면 T 라는 타입이 number와 string 타입을 상속했다고 보면 된다.

상속이라는 키워드 때문에 헷갈린다면 부분집합의 맥락으로 이해해도 충분히 해석이 가능하다.

T가 number | string의 부분집합이라고 생각하면 T는 number 혹은 string이 되는 것이다.

여기에 대한 예를 들면 아래와 같다.

  • <10 extends number> // true
  • <number extends 10> // false

실제로 타입스크립트에서 기본적으로 내장되어있는 유틸리티 타입을 통해 또하나의 케이스를 살펴보자

Exclude

type Exclude<T, U> = T extends U ? never : T

Exclude는 겹치는 타입에 대해서 제외할 수 있는 유틸리티 타입이다.

T와 U 두가지의 제네릭 타입을 받는데, 아까 살펴보았던 extends를 활용했다.

T는 U를 상속 했을 때 상속이 되어 있다면 never 타입을, 아니라면 T 제네릭 타입을 넣도록 했다.

여기서 갑작스레 never 타입이 등장해서 당황했을 수도 있는데, 여기서의 never는 공집합의 느낌을 주며 어떠한 타입값도 가질 수 없는 것을 뜻한다. 즉, 여기서는 아무런 타입을 할당하지 않는다고 보면 된다.

실제로 사용한 코드를 보면 다음과 같다.

interface Person {
  name: string
  age: number
}

interface Product {
  name: string
  price: number
}

type Excluded = Exclude<Person | Product, Product> // type Excluded = Person

Person 혹은 Product 타입 중 Product를 제외하니 Person 타입만 남게 되었다.

이처럼 extends 키워드를 통해서 특정 타입에 대해서 강제하고 삼항연산자를 통해 조건을 걸어 여러가지 타입을 만들어 낼 수 있다.

extends를 활용한 헬퍼타입 실습

이제 어느정도 제네릭extends에 대해서 느낌을 잡았을테니 (아닌가..? 😅) 두 가지 간단한 헬퍼타입을 만들어보자

Readonly

Readonly는 특정 인터페이스 타입을 HelperReadonly로 감싸면 전체 프로퍼티에 대해 readonly 키워드를 붙여서 타입을 만들어주는 헬퍼타입이다.

처음 헬퍼타입을 만들기 전의 상황은 아래와 같다.

type HelperReadonly<T> = any

const person: HelperReadonly<Person> = {
  name: 'fronttigger',
  age: 30,
}

person.name = 'backtigger'
person.age = 29

명시한 any를 이제 프로퍼티를 수정하려고 할 때 readonly인 경우 출력되는 에러인 Cannot assign to 'name' because it is a read-only property. 가 출력되도록 작업하면 된다.

문제 풀어보기

그 결과는 아래와 같다.

type HelperReadonly<T> = {
  readonly [key in keyof T]: T[key]
}

const person: HelperReadonly<Person> = {
  name: 'fronttigger',
  age: 30,
}

person.name = 'backtigger' // Cannot assign to 'name' because it is a read-only property.
person.age = 29 // Cannot assign to 'age' because it is a read-only property.

제네릭 타입 하나만 사용한 간단한 헬퍼타입이다.

이제 프로퍼티에 접근하여 수정하려고 하면 원하던 에러가 출력된다. 🎉

Pick

Pick은 두가지 제네릭 타입을 받는데 첫 번째 타입에 대해서 원하는 타입을 두 번째 타입에서 union 타입으로 명시함으로써 명시한 타입에 대해서 새롭게 타입을 만드는 헬퍼타입이다.

만들 헬퍼타입의 초기 형태다.

type HelperPick<T, K> = any

type PersonPick = HelperPick<Person, 'name'> // type PersonPick = { name: string; }

const person: PersonPick = {
  name: 'backtigger',
}

최종적으로는 person에 name만 입력 가능하도록 만드는 것이다.

문제 풀어보기

그 결과는 아래와 같다.

type HelperPick<T, K extends keyof T> = {
  [key in K]: T[key]
}

type PersonPick = HelperPick<Person, 'name'>

const todo: PersonPick = {
  name: 'backtigger',
}

두 번째 제네릭 타입에 extends를 사용하여 Person 타입의 프로퍼티 값을 가져왔으며, 이를 프로퍼티에서 사용하고 그 값을 선언해주면 완성된다.

여기까지 제네릭extends를 활용해 헬퍼타입을 만들어보았다.

사실 너무나도 간단한 실습이었지만 이를 기본으로 해서 사용되는 여러가지 타입에 대한 분석과 조금씩 고도화된 타이핑을 하다보면 충분히 잘 다룰 수 있을 것이다! 👨🏻‍💻

모든 코드는 Github에 저장되어 있다.

참고자료