반응형
https://toss.tech/article/template-literal-types
탬플릿 리터럴 타입은 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 파서 만들기
아래 예제를 고통스럽게 분석하면서 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
반응형
'FrontEnd' 카테고리의 다른 글
[TypeOrm]ORM을 프로젝트에 도입할 때 주의할점 (1) | 2022.04.30 |
---|---|
XState : 액터 모델 간단히 알아보기 (0) | 2022.04.30 |
React와 Typescript를 함께 사용하기 : 유용한 유틸리티 타입 (0) | 2022.04.28 |
React와 Typescript를 함께 사용하기 : 간단한 6개의 팁 (0) | 2022.04.28 |
타입스크립트 : 모듈 확장(module augmentation)으로 서드파티 관련 타입 문제 해결하기 (0) | 2022.04.28 |