avatar
Published on

타입스크립트의 제네릭은 적절한 네이밍과 함께 사용하자

Author
  • avatar
    Name
    yceffort

타입스크립트의 제네릭은 언어가 제공하는 강력한 기능 중 하나다.제네릭을 사용하면, 타입스크립트에서 매우 유연하고 동적인 타입 생성을 가능하게 한다. 특히, 타입스크립트 최신버전에 들어서 string 리터럴 타입과 재귀 조건 타입이 생겨나면서, 재밌는 작업을 수행할 수 있다.

(시작에 앞서) string literal type과 조건 타입

type OnString = `on${string}`
const onClick: OnString = 'onClick'
// Type '"handleClick"' is not assignable to type '`on${string}`'
const handleClick: OnString = 'handleClick'

infer 키워드를 사용하면 더 재밌는 것도 할 수 있다. infer는 조건부 타입으로, extends 키워드 오른쪽에 사용할 수 있다.

type Unpack<A> = A extends Array<infer E> ? E : A

type Test = Unpack<Apple[]>
// Apple
type Test = Unpack<Apple>
// Apple

만약 배열로 판단되면 배열에서 그 타입만을, 아니면 그 타입을 그대로 리턴하도록 했다.

아래 예제도 살펴보자.

type ToCamel<S extends string> = S extends `${infer Head}_${infer Tail}`
  ? `${Head}${Capitalize<ToCamel<Tail>>}`
  : S

type T0 = ToCamel<'foo'> // "foo"
type T1 = ToCamel<'foo_bar'> // "fooBar"
type T2 = ToCamel<'foo_bar_baz'> // "fooBarBaz"

넘어온 제네릭을 재귀적으로 살펴보면서, _스타일의 snake case를 camelCase로 변경하였다.

제네릭과 String literal type, 그리고 조건에 따른 타입을 활용한 복잡한 예제

type RouteParameters<T> = T extends `${string}/:${infer U}/${infer R}`
  ? { [P in U | keyof RouteParameters<`/${R}`>]: string }
  : T extends `${string}/:${infer U}`
  ? { [P in U]: string }
  : {}

type X = RouteParameters<'/api/:hello/:javascript/typescript/:world'>
// type X = {
//     hello: string;
//     javascript: string;
//     world: string;
// }

제네릭 타입을 선언하고, 또 그 안에서 제네릭타입을 선언했다. 이 작업은 <> 사이에서 이루어졌다. 그리고 이를 재귀적으로 처리함으로써, :parameter들을 객체 형태의 타입으로 처리할 수 있었다.

어려우니까, 처음으로 돌아와보자.

일단 앞선 예는 복잡하니, 다시 기초로 돌아와보자.

type에 제네릭을 넘겨주려면 우리는 보통 아래와 같이 처리한다.

type Foo<T extends string> = ...

그리고 이 제네릭 타입은 기본값을 가질 수도 있다.

type Foo<T extends string = "hello"> = ...

기본값을 사용하게 되면, 해당 제네릭의 사용처를 제한하는 것이기 때문에 순서가 중요해진다. 이는 자바스크립트의 함수와 비슷하다. 제네릭도 일종의 자바스크립트 함수의 인수의 성질과 비슷하다. 따라서 우리는 제네릭도 적절하게 네이밍을 해주는 것이 중요하다.

제네릭 타입 파라미터에 이름을 지어주기의 중요성

대부분의 제네릭 타입은 T로 시작하는 네이밍을 지어준다. 보통, T를 쓰게 되면, 그 이후에는 U V W를 쓰거나, key의 약자라는 의미로 K를 쓰곤 한다.

거의 모든 프로그래밍언어와 마찬가지로, 제네릭이라는 컨셉은 오래전 부터 존재해 왔다. 이런 제네릭의 시작은 Ada ML과 같은 70년대 언어에서 그 기원을 찾아볼 수 있다.

뭐, 그 때부터 T를 쓰는 것이 시작되었는지 어쨌는지 모르겠지만 어쨌거나 대부분이 제네릭을 선언할 때 T를 사용한다는 사실엔 변함이 없고, 우리또한 모두 그것에 익숙하다.

그러나 한개 일 때는 모르겠지만, 두 개부터는 조금씩 이해가 안된다. Pick<K, U>를 예를 들어보자. K U만을 봐서는 이게 무엇을 의미하는지 알 수가 없다. 아무도 저 두 알파벳만 봐서는, U가 객체타입이고, K가 그 U에 있는 키 중 하나는 사실을 알 수 없다.

따라서 우리는 공식 문서처럼, 아래와 같이 사용한다면 훨씬 이해하기 편하다.

type Pick<Obj, Keys> = ...

https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys

제네릭을 네이밍하는 방법

타입은 일종의 문서화라고 볼수 있고, 타입 파라미터는, 일반적인 함수와 마찬가지로 이름을 통해서 부를 수 있다. 일반 함수와 마찬가지로, 제네릭 네이밍에 대한 가이드를 살펴보자.

  1. 모든 타입 파라미터는, 타입과 마찬가지로 대문자로 시작한다.
  2. 제네릭이 사용법이 완전히 명확하다면, 한단어를 사용한다. RouteParameters의 경우에는 route를 받는 것이 명확할 것이다.
  3. 왠만하면 T를 쓰지말자. (이는 너무 제네릭하다) route와 마찬가지로 명확하게 나타낼 수 있는 단어로 사용하자.
  4. 한 글자, 또는 짧은 단어, 그리고 약어를 사용해야 하는 경우는 거의 없다.
  5. 빌트인 타입과 구별하기 위해서 prefix를 사용하자.
  6. prefix를 제네릭네이밍에 사용하면 더 용도를 뚜렷이 구분할 수 있게 해준다. Obj보다는, URLObj가 낫다.
  7. infer의 경우에도 제네릭 타입과 마찬가지의 룰이 적용된다.

위 규칙을 잘 염두해두고, RouteParameters를 정확한 제네릭 네이밍과 함께 다시 써보자.

type RouteParameters<Route> =
  Route extends `${string}/:${infer Param}/${infer Rest}`
    ? { [Entry in Param | keyof RouteParameters<`/${Rest}`>]: string }
    : Route extends `${string}/:${infer Param}`
    ? { [Entry in Param]: string }
    : {}

확실히 이전보다 읽기가 편해졌다. (물론, 저 타입이 복잡하지 않다는 건 아니다 😑)

제네릭을 사용할때, T K U와 같은 약어보다는 적절한 네이밍을 사용한다면, 보다 다른 개발자들과 프로그래밍을 하는데 수월해질 것이다.