타입스크립트 제네릭은 정말 강력하다.
제네릭 자체가 언어적으로 매우 강력한 기능이지만, 타입스크립트의 제네릭은 다른 언어의 기능과는 본질적으로 차원이 다르다.(자바라던가, C#이라던가. 물론 C++의 메타 템플릿 프로그래밍이라던가, rust의 매크로 보다는 약하지만.)
나는 타입스크립트에 입문한 3달간 정말 다양하고 말도 안되는 제네릭의 활용성을 보았고 이를 공유하고자 이 글을 쓰게 되었다.
그래서 첫번째로 소개할 타입스크립트 제네릭의 마법, literal의 정적 분석에 대해 이야기 해 보겠다.
아래와 같은 코드는 서버 프로그래머, 혹은 SPA앱을 만드는 클라이언트 프로그래머들에게 익숙할 것이다.
server.route("/user/:user_id", (param)=>{
console.log(param.user_id)
})
이런 식으로 특정 문자열로부터 특수한 패턴에 일치하는 문자열을 추출하는 기능은, 굳이 위와 같은 라우팅이 아니더라도 많이 쓰인다.
그런데 여기서 사실 불안전한 타입이 들어간다. 바로 param
이 Record<string, string>
으로 추론된다는 것이다.
우리는 사람이기에 param
에는 user_id
필드가 있을 것이라 예상한다.
그러나 지금까지의 언어에서는 string
타입은 오로지 string
타입으로만 다뤘기에 컴파일러 기준에서는 param
에 어떤 값들이 올 수 있는지 알 수 없었다.
즉 위의 코드는 아래처럼 타입을 추론한다.
server.route("/user/:user_id", (param: Record<string, string>)=>{
console.log(param.user_id)
})
그런데 아래의 코드처럼 타입을 추론하게 만들 수는 없을까?
server.route("/user/:user_id", (param: { user_id: string })=>{
console.log(param.user_id)
})
일단 먼저 답을 하자면 다음과 같다. 가능하다.
코드는 아래와 같다.
type ParsePath<P extends string> =
P extends `/${infer Postfix}`
? ParsePath<Postfix>
: P extends `:${infer Param}/${infer Postfix}`
? Param | ParsePath<Postfix>
: P extends `:${infer Param}`
? Param
: P extends `${string}/${infer Postfix}`
? ParsePath<Postfix>
: never
function parseRouteLiteral<Path extends string>
(
path: Path,
handler: (param: Record<ParsePath<Path>, string>) => void
) { ... }
위에서 ParsePath
라는 제네릭 타입은 정적으로 주어진 Path
를 분석해 Path
에서 가능한 모든 Param
들을 추출한다.
말로만 설명하면 이것이 뭔 의미인지 전달이 힘들다. 실제 vscode같은 IDE에서 화면을 통해 이것이 어떤 의미인지 알아보자.
이 기능은 타입스크립트에서 정적 문자열을 string
타입이 아닌 정적 문자열로 해석하기에 가능하다.
말로 이야기하니 복잡한데 이는 아래 사진을 통해 쉽게 이해할 수 있다.
위 사진은 vscode에서 type hint를 켜서 변수 타입이 뭔지 보는 옵션이다.
여기서 보면 let
으로 선언된 a1
은 "AAA"
를 string
타입으로 인식하지만
const
로 선언된 a0
는 "AAA"
를 "AAA"
타입으로 이해한다.
즉 컴파일러가 a0
는 "AAA"
라는 타입으로 인식하기에 컴파일 타임에 위와 같은 변태적인유용한 기술을 쓸 수 있게 된다.
타입스크립트에서는 string이 모든 가능한 문자열들의 union 타입이다.
string은 기본적으로 무한집합이기에 이해하기 쉬운 boolean으로 예를 들어보자.
위의 코드에서 알 수 있듯이 boolean
은 true
와 false
의 union 타입이다.
string
은 이와 같이 모든 가능한 문자열의 union 타입으로 생각하면 될 것 같다.
다만 무한집합인 union 타입일 뿐.
즉
type A = '' | 'a' | 'b' | 'c' | .........
위 코드처럼 모든 가능한 문자열 경우의 수를 합치면 A는 결과적으로 string이 될 것이다.
이처럼 타입스크립트에서는 string 타입이 사실 타입의 최소 단위가 아니다.
마치 우리가 과학 시간에 원자가 물질의 최소 단위인줄 알았더니 이를 분해하면 쿼크가 나온다고 배웠듯이 string 역시 쪼개보니 여러개의 literal로 구성된 타입인 것이다.
type X = A extends B ? C : D
위와 같은 형태의 구문은 제네릭 계의 if문이자, 모든 타입스크립트 마법의 알파이자 오메가이다.
이녀석은 타입스크립트 제네릭이 튜링 완전하게 만드는 핵심 요소 중 하나로 제네릭을 마치 일반적인 프로그래밍 언어처럼 쓸 수 있도록 만든다.
물론 진짜 그렇다는 건 아니고, 그만큼 강력하다고 말하고 싶은 것이다.
이번 글에서 가장 핵심적인 기능인데 infer 구문은 리터럴의 일부와 매칭시키는 것이 가능하다.
말로 하면 복잡한데 아래 코드를 보면 쉽다.
type Split<S extends string, Splitor extends string> =
S extends `${infer BEFORE}${Splitor}${infer AFTER}`
? [BEFORE, ...Split<AFTER, Splitor>]
: S extends ``
? []
: [S]
보다시피, Split 타입을 이용해 정적 튜플 타입을 문자열로 부터 추측 가능하다.
위 코드는 SQL에서 주로 쓰이는 ? 개수만큼 패러미터를 받아야 하는 코드를 작성할때, 정확히 필요한 개수만큼 주어진 배열을 받는 경우에만 컴파일 가능하도록 하게 만드는 제네릭 코드이다.
맨 밑줄의 에러는 ? 개수가 2개인데 실제 주어잰 패러미터가 3개라서 난 오류이다.
타입스크립트 제네릭은 매우매우매우 강력하다.
그리고 위의 예시는 제네릭 사용 사례의 아주 일부일 뿐이고 실제로 이보다 훨신 더 강력한 수많은 사례가 있다.
앞으로도 타입스크립트 제네릭을 활용한 다양한 사용법을 포스팅하고자 한다.