본문 바로가기

FrontEnd

타입스크립트 : 탬플릿 리터럴 타입으로 타입 안전하게 코딩하기

반응형

https://toss.tech/article/template-literal-types

 

Template Literal Types로 타입 안전하게 코딩하기

TypeScript 코드베이스의 타입 안전성을 한 단계 올려줄 수 있는 Template Literal Type의 뜻과 응용에 대해 알아봅니다.

toss.tech

 

탬플릿 리터럴 타입은 Parser 개발에 매우 효과적이다.

해당 글 말미에 소개된 예제들의 타입 구현을 따라가본다.

 

기본적으로 모두 재귀와 타입 추론(infer), 공변과 반공변을 적절히 사용하여,

해당 구현들을 공부해두면 다른 구현들을 이해하는데 큰 도움이 될 것이다.

예제 1 : Express route parameter에서 파라미터 추출하기 (난이도 하)

type C = ExtractRouteParams<'/posts/:postId/:commentId'>; // type C = { postId: string; commentId: string;}
type P = ExtractRouteParams<'/posts/:postId'>;  // type P = { postId: string;}
type S = ExtractRouteParams<string> // Record<string, string>
type E = ExtractRouteParams<'/noParam'> // {}


// strng 하위 타입만 가능.
type ExtractRouteParams<T extends string> =
  string extends T
  // 엣지 케이스 : 말 그대로 string 할당
  ? Record<string, string>
   //   1 : 뒤에 / 있는 경우, Start/ :변수 / Rest
   // : 변수 | Rest에 해당 타입을 재귀하여, 변수의 union type을 key로, value는 반드시 string
  : T extends `${infer Start}:${infer Param}/${infer Rest}`
  ? {[k in Param | keyof ExtractRouteParams<Rest>]: string}
    // 2 : 뒤에 / 없는 경우. 엣지 케이스
  : T extends `${infer Start}:${infer Param}`  
  ? {[k in Param]: string}
  // 엣지 케이스 : (:변수) 없는 경우
  : {};

예제 2 : 타입 안전한 document.querySelector (난이도 중)

/**
 * Example
 */
// declare은 앰비언트 모듈에서 사용된다. 이는 기존 js로 구현된 라이브러리의 껍데기 역할만 하기 때문에, 실제로 해당 코드가 있는 것처럼 사용할 수 있다.
declare function querySelector<T extends string>(query: T): QueryResult<T>;

// T는 string 타입의 서브타입이다. T에서 Element들의 이름을 가져온다.
type QueryResult<T extends string> = MatchEachElement<GetElementNames<T>>;

// 1 MatchEachElement 파트 : GetElementNames의 결과인 V는 엘리면트 태그 ['div','a'] 배열
type MatchEachElement<V, L extends Element | null = null> = 
    V extends [] // 엣지 케이스.모든 결과를 처리하였거나, V가 진짜 []. 지금까지 모은 결과인 L을 뱉거나, V가 진짜 []이면 Element | null 리턴. 하단 예제 f. 참조.
        ? L 
        : V extends [string] // 엣지 케이스. 처리할 요소가 하나만 남음
        ? L | ElementByName<V[0]>  // 이전 결과와 union 후 리턴. 하단 예제 c 참조.
        : V extends [string, ...infer R] // 처리할 요소들이 여러개면
        ? MatchEachElement<R, L | ElementByName<V[0]>> // V의 맨 앞을 처리한 결과를 L과 유니온 하고 V위치에 R을 전달.
        : L; // 예외 케이스.

// V의 키워드 'div', 'a'를 이용해 실제 요소의 타입을 판별함
type ElementByName<V extends string> = 
    V extends keyof HTMLElementTagNameMap 
        ? HTMLElementTagNameMap[V] 
        : V extends keyof SVGElementTagNameMap 
        ? SVGElementTagNameMap[V] 
        : Element; // 예외 케이스


// 2 GetElementNames 파트 : T에서 Element의 이름들을 얻는다. , 로 구분되어 있으면 태그명은 하나가 아니다.
type GetElementNames<V extends string> = GetEachElementName<Split<V, ','>>; // ,로 분리된 문자열 배열에서 Element들의 이름 배열을 얻는다. (string) => string[] split by ','
// Delimiter를 이용해 String을 재귀적으로 분리한다.
type Split<S extends string, D extends string> = S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S];
// 이제 문자열 배열 V에서 이름을 얻는다.
// L은 지금까지 모은 요소(+선택자)들의 List역할을 한다.
type GetEachElementName<V, L extends string[] = []> = 
    V extends []  // 엣지 케이스 : 요소가 하나도 없는 경우 - 모든 요소 순회 종료
        ? L 
        : V extends [string]  // 엣지 케이스 : 요소가 하나인 경우, 지금까지 모은 L과 V의 마지막 결과를 처리한 리스트를 합하여 리턴
        ? [...L, GetLastElementName<V[0]>] 
        : V extends [string, ...infer R]  // 요소가 한 개 이상인 경우,
        ? GetEachElementName<R, [...L, GetLastElementName<V[0]>]> // V[0]을 처리하고 남은 R을 V 위치에 전달 /  지금까지 모은 L과 V의 결과를 처리한 리스트를 합하여 L 위치에 전달.
        : [];
    // 각 요소의 선택자를 파싱하여 최종 엘리먼트의 이름만 가져온다. 
    // 먼저 스트링에 트림을 적용하고, 마지막 ' ' 뒤를 가져오고(TakeLast), " " 뒤에서도 마지막 '>' 의 뒤 스트링에서 css 모디파이어를 제거하고 태그명만 가져온다. 즉 마지막 태그명만 가져오는 것이다.
    type GetLastElementName<V extends string> = TakeLastAfterToken<TakeLastAfterToken<V, ' '>, '>'>;
        // Token 이후 마지막 요소를 가져온다.
        // 1. V를 트림하고 T를 딜리미터로 V를 쪼댄다
        // 2. 1의 결과의 가장 마지막 태그명을 가져온다.
        type TakeLastAfterToken<V extends string, T extends string> = StripModifiers<TakeLast<Split<Trim<V>, T>>>; 
                        // Trim 3총사는 말 그대로 Trim이다.
                        type Trim<V extends string> = TrimLeft<TrimRight<V>>;
                        type TrimLeft<V extends string> = V extends ` ${infer R}` ? TrimLeft<R> : V;
                        type TrimRight<V extends string> = V extends `${infer R} ` ? TrimRight<R> : V;          
                        // Delimiter를 이용해 String을 재귀적으로 분리한다.
                        // type Split<S extends string, D extends string> = S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S]; 
                    // 배열에서 가장 마지막 요소를 가져온다.
                    type TakeLast<V> = V extends [] ? never : V extends [string] ? V[0] : V extends [string, ...infer R] ? TakeLast<R> : never;
            // V에서 css 모디파이어를 전부 제거한다. 뒤의 StripModifier를 날리고 앞부분을 가져온다.
            // . 앞을 살려야 하니 해당 순서가 매우 중요해 보인다.
            type StripModifiers<V extends string> = StripModifier<StripModifier<StripModifier<StripModifier<V, '.'>, '#'>, '['>, ':'>;
                // V에서 M 모디파이어를 제거하고 앞에만 가져온다 (div.class => div)
                type StripModifier<V extends string, M extends string> = V extends `${infer L}${M}${infer A}` ? L : V;




const a = querySelector('div.banner > a.call-to-action') //-> HTMLAnchorElement
const b = querySelector('input, div') //-> HTMLInputElement | HTMLDivElement
const c = querySelector('circle[cx="150"]') //-> SVGCircleElement
const d = querySelector('button#buy-now') //-> HTMLButtonElement
const e = querySelector('section p:first-of-type'); //-> HTMLParagraphElement
const f = querySelector(''); // Element | null

나중에 가독성을 고려하여 한번 더 정리해야겠다.

 

3 TypeScript로 JSON 파서 만들기

 

Jamie Kyle 🏳️‍🌈 on Twitter

“I am so sorry for this... I wrote a JSON parser using @typescript's type system https://t.co/0dxc9Q7A9D”

twitter.com

아래 예제를 고통스럽게 분석하면서 3항 연산자를 마스터한것 같다.

나중에 한번 더 보면서 주석을 정리해야겠다.

// Error의 브랜디드 타입. T가 같은 문자열이면 같은 오류로 처리함
type ParserError<T extends string> = { error: true } & T

//  " " 혹은 "\n" 제거
type EatWhitespace<State extends string> =
  string extends State // EatWhitespace<string>인 경우 오류
  ? ParserError<"EatWhitespace got generic string type">
  : State extends ` ${infer State}` | `\n${infer State}` // " " 혹은 "\n" 제거
  ? EatWhitespace<State>
  : State //  " " 혹은 "\n" 제거된 결과

// 좌측 기존 Memo에 &로 확장 ex : {key1: ["value1", null];} & {key2: "value2";}
type AddKeyValue<Memo extends Record<string, any>, Key extends string, Value extends any> =
  Memo & { [K in Key]: Value }


// 최상단 API
type ParseJson<T extends string> =
  ParseJsonValue<T> extends infer Result
  ? Result extends [infer Value, string]
  ? Value
  : Result extends ParserError<any>
  ? Result
  : ParserError<"ParseJsonValue returned unexpected Result">
  : ParserError<"ParseJsonValue returned uninferrable Result">;

// State로 남은 문자열을 재귀적으로 넘기면서 처리함.
type ParseJsonValue<State extends string> =
  string extends State // ParseJsonValue<string>인 경우 오류
  ? ParserError<"ParseJsonValue got generic string type">
  : EatWhitespace<State> extends `null${infer State}` // null + 문자열.
  ? [null, State] // [null,남은문자열]
  : EatWhitespace<State> extends `"${infer Value}"${infer State}` // value + 문자열.
  ? [Value, State] // [Value, 남은문자열]
  : EatWhitespace<State> extends `[${infer State}` // 배열의 시작
  ? ParseJsonArray<State>
  : EatWhitespace<State> extends `{${infer State}` // 객체의 시작
  ? ParseJsonObject<State>
  : ParserError<`ParseJsonValue received unexpected token: ${State}`> // 엣지 케이스. - 규칙에 해당되지 않아 오류

// 배열 파싱 시작. [ 뒤부터 동작함.
type ParseJsonArray<State extends string, Memo extends any[] = []> =
  string extends State 
  ? ParserError<"ParseJsonArray got generic string type">  // return ParseJsonArray<string>인 경우 오류 
  : EatWhitespace<State> extends `]${infer State}` // 엣지 케이스. 베열 종료. 배열에 값 추가하지 않음 a
  ? [Memo, State] // return [memo , 남은 문자열] (a)
  : // 뒤는 전부 !a & 포함
  ParseJsonValue<State> extends [infer Value, `${infer State}`]  // 배열에 새로 값을 추가 b
    ? EatWhitespace<State> extends `,${infer State}` // 다음 값이 ,로 시작하는 문자열 - 재귀 필요  c
    ? ParseJsonArray<EatWhitespace<State>, [...Memo, Value]>  // 뒤부터 다시 배열 파싱 시작. Memo에 이전 결과 전달 [...Memo,Value]  return  (b & c)
    : EatWhitespace<State> extends `]${infer State}` // 배열 종료 d
    ? [[...Memo, Value], State] // 다음 값이 없음. 재귀 중지. result는 배열([])  (b & !c & d)
    : ParserError<`ParseJsonArray received unexpected token: ${State}`> // 엣지 케이스 : 토큰 파싱 실패 (b & !c & !d)
  
  : ParserError<`ParseJsonValue returned unexpected value for: ${State}`> // 값 파싱 실패 (!b & !c & !d)



// Object 파싱 시작. { 이후부터 처리
type ParseJsonObject<State extends string, Memo extends Record<string, any> = {}> =
  string extends State   // ParseJsonObject<string>인 경우 오류
  ? ParserError<"ParseJsonObject got generic string type">
  : EatWhitespace<State> extends `}${infer State}` // 아무것도 안하고 객체 종료 a
  ? [Memo, State] // return. a 케이스
  // 뒤는 전부 !a & 케이스
  : EatWhitespace<State> extends `"${infer Key}"${infer State}` // { 뒤에 key 존재 b
  ? EatWhitespace<State> extends `:${infer State}` // key 뒤에 value 존재 c
  ? ParseJsonValue<State> extends [infer Value, `${infer State}`] // value 파싱 성공 d
  ? EatWhitespace<State> extends `,${infer State}` // value가 배열임. e
  ? ParseJsonObject<State, AddKeyValue<Memo, Key, Value>> // return 객체에 속성 계속 추가 b & c & d & e
  : EatWhitespace<State> extends `}${infer State}` // value를 처리했더니 이제 객체가 끝남. f
  ? [AddKeyValue<Memo, Key, Value>, State] // return b & c & d & !e & f 
  : ParserError<`ParseJsonObject received unexpected token: ${State}`> // b & c & d & !e & !f 
  : ParserError<`ParseJsonValue returned unexpected value for: ${State}`> // b & c & !d & !e & !f 
  : ParserError<`ParseJsonObject received unexpected token: ${State}`> // b & !c & !d & !e & !f 
  : ParserError<`ParseJsonObject received unexpected token: ${State}`> // !b & !c & !d & !e & !f 



type Json = ParseJson<'{ "key1": ["value1", null], "key2": "value2" }'>;
// type Json = {key1: ["value1", null];} & {key2: "value2";}

 

 

더 많은 예제 보기

추가로 SQL, XML 파서 등을 구경해보자.

https://github.com/ghoullier/awesome-template-literal-types#kysely

 

GitHub - ghoullier/awesome-template-literal-types: Curated list of awesome Template Literal Types examples

Curated list of awesome Template Literal Types examples - GitHub - ghoullier/awesome-template-literal-types: Curated list of awesome Template Literal Types examples

github.com

 

반응형