URI 직렬화로 알아보는 문자열 타입 추론하기 (부제: 타입스크립트 실전 7가지 기술)

미누캉·2021년 8월 2일
1

웹 애플리케이션을 개발하다 보면 수 많은 경로(라우팅)가 생기고, 이에 대한 규칙을 세우고, 해당 경로에 알맞는 경로 매개 변수를 넘겨주어야 합니다. 프론트엔드에서 react-router, vue-router 등에서 쓰이는 path-to-regexp 패키지가 대표적으로 이러한 경로 생성에 대한 인터페이스를 제공하고, 직렬화를 도와줍니다.

저희는 이러한 경로 인터페이스를 생성하고, 매개 변수 타입까지 제공할 수 있는 직렬화 헬퍼를 아래와 같이 만들어서 사용했습니다.

// route/urls.ts
export const URLs = {
  Home,
  // HelpDesk Routes
  HelpDesk: createUriRule('/helpdesk'),
  HelpDeskSearch: createUriRule<{ keyword: string }>('/helpdesk/search'),
  HelpDeskCategory: createUriRule<{ category1: string; category2?: string }>(
    '/helpdesk/categories/:category1/:category2?'
  ),
  HelpDeskFaq: createUriRule<{ faqId: string }>('/helpdesk/faq/:faqId'),
};

// example
const link = URLs.HelpDeskFaq.serialize({ faqId: String(questionItem.faqId) });
return <Link href={link} passHref />;

단순히 경로 인터페이스에 해당하는 문자열을 넣고, 제네릭을 통해 매개 변수의 타입을 정의했습니다.

시간이 흘러 타입스크립트에 템플릿 리터럴 타입으로 문자열 내부를 추정할 수 있는 기능이 추가되었고, 라우팅 경로 규칙이라는게 정해져있어서 이를 이용하여 명시적으로 제네릭을 넘겨주지 않아도 라우팅 경로 문자열만으로 해당 경로에 쓰이는 매개 변수들의 타입 추정을 진행해봤습니다.

결과

import { URISchema } from 'uri-manager';

const filter = new URISchema('/filter/:category?');

filter.serialize(); // ✅ "/filter"
filter.serialize({ category: 'foo' }); // ✅ "/filter/foo"

const productDetail = new URISchema('/product/:productId(\\d+)');

productDetail.serialize(); // ❌ TypeError: Expected 1 arguments, but got 0.
productDetail.serialize({ productId: 'foo' }); // ❌ TypeError: Type 'string' is not assignable to type 'number'
productDetail.serialize({ productId: 10 }); // ✅ "/product/10"

해당 패키지의 소스 코드는 https://github.com/minuukang/uri-manager 에서 확인할 수 있습니다.

읽기 전에!

다음과 같은 선행지식이 없다면 이 아티클을 읽는데 어려움이 있을 수 있습니다.

부제에 관하여

이 아티클은 다음과 같은 7가지 타입스크립트 테크닉을 사용하고 있습니다.

  1. 조건부 타입을 통해 문자열 내부 추론할 수 있습니다.
  2. 튜플 전개 구문을 사용하여 타입 재귀를 구현할 수 있습니다.
  3. 튜플/객체 타입의 값을 유니온으로 변환할 수 있습니다.
  4. 유니온 타입에서 원하는 값만 필터링(filter)하고 원하는 형태로 변형(map) 수 있습니다.
  5. 객체 타입의 모든 값이 옵셔널임을 증명할 수 있습니다.
  6. 함수의 매개 변수를 조건부 타입으로 옵셔널 여부를 결정할 수 있습니다.
  7. 클래스 생성자 오버라이딩으로 상황에 따라 다른 타입을 사용할 수 있습니다.

타입 추론이란?

타입스크립트에서는 extends 키워드를 통해 양항 타입의 포함관계를 추정하고, 이를 매개 변수 형태의 제네릭으로 사용하여 타입의 범위를 강제하거나, 삼항 연산자 형태로 추정된 일부결과를 새롭게 만들 수 있습니다. 이런 형태를 조건부 타입이라고 합니다.

type A1 = true extends boolean ? 1 : 0; // 1; true는 boolean에 속하는 '포함 관계' 임으로, A1의 값은 1이다.
type A2 = boolean extends true ? 1 : 0; // 0; boolean은 true에 속하지 않음으로, A2의 값은 0이다.

위 결과를 보면 쉽게 "좌항이 우항에 포함한다" 라고 이해 하면 됩니다.

알아보기 : unknown 타입은 모든 타입(string, number, boolean 등등)의 포함관계입니다. 쉽게 말해 최상위에 위치한 타입 개념입니다. 우리는 이 unknown 타입을 any안전하게 대체하여 사용할 수 있습니다. (관련링크: 안전한 any 타입 만들기 - overcurried)

그럼 이제 위에서 설명한 추정된 일부결과를 가져올 수 있다고 했는데요, 이 과정을 추론이라고 합니다. infer 키워드를 통해서 제네릭에 포함된 타입을 추론할 수 있습니다.

type PromiseResolve<P> = P extends Promise<infer V> ? V : never;
type A3 = PromiseResolve<Promise<string>>; // string

위와 같이 Promise 타입인 것을 추정하고, 안에 들어 있는 제네릭은 추론을 통해 직접 명시하지 않아도 이를 '참' 결과에 참조할 수 있습니다.

템플릿 리터럴 타입 추론

type RemoveWrapBracket<Str extends string> = Str extends `(${infer Find})` ? Find : Str;
type A4 = RemoveWrapBracket<"(hello)">; // hello

타입스크립트 4.3부터 템플릿 리터럴 타입을 사용 할 수 있게 되었는데요, 해당 문자열에 대한 타입 추론 또한 가능해져 문자열 형태의 타입의 형태를 위와 같이 앞 뒤에 있는 괄호 문자인지 추정하고, 그 괄호 안에 있는 내용을 추론해서 이를 결과로 사용할 수 있습니다.

경로 타입 추론하기

우선 경로에 쓰이는 문자열의 스펙을 알아봅시다. 우리는 경로 문자열 /를 기준으로 하나하나의 규칙을 '토큰' 이라고 칭하겠습니다.

/path/:param1/:param2(foo|bar)/:param3(\\d+)/:param4?
  • 모든 토큰은 : 로 시작된다.
  • 끝에 ? 물음표로 이 토큰이 필수적이지 않은 규칙이라고 명시할 수 있다.
  • 토큰 명 뒤에 괄호를 통해 정해진 값만 취하거나((foo|bar)), 정규식으로 숫자만을 가져올 수 있게 한다((\\d+))

경로 토큰 나누기

가장 먼저 해야하는 작업은 경로 문자 / 를 기준으로 각각 일반 경로인지, 토큰인지를 구분해야합니다. 그래서 특정한 문자열을 기준으로 이를 배열로 만드는 타입을 구현해보겠습니다.

type Split<Str extends string, Sep extends string> =
  Str extends `${infer Left}${Sep}${infer Right}`
    ? [Left, ...Split<Right, Sep>]
    : [Str];
type A5 = Split<'1|2|3', '|'> // ['1', '2', '3']

첫번째 제네릭을 통해 전체 문자열을 받고, 두번째 제네릭으로 기준으로 나눌 문자열을 받습니다. 템플릿 리터럴 추정을 통해 기준 문자의 앞 뒤를 추론하고, 왼쪽 값을 첫번째 튜플로, 오른쪽 값은 Split 타입을 재귀하고 전개 구문(...)을 통해 나머지 튜플로 지정합니다. 만일 추정이 되지 않는다면 전체 문자열을 튜플에 넘겨줌으로써, 재귀를 종료합니다.

튜플 타입으로 만드는데 성공했지만, 타입스크립트에서는 튜플 타입으로 원하는 형태로 필터링과 변형이 힘듭니다. 이를 유니온으로 만들어 줌으로써 해결할 수 있습니다.

type Arr = ['foo', 'bar'];

type A6 = Arr[0] // 'foo'
type A7 = Arr[1] // 'bar'

type A9 = Arr[number] // 'foo' | 'bar'

배열 타입에 정직하게 해당 인덱스 숫자로 접근하면 해당 인덱스에 들어있는 타입을 가져옵니다. 여기서 숫자들의 공통 타입인 number 를 통해 접근하면 숫자를 통해 접근할 수 있는 모든 타입을 가져오게 됩니다.

객체나 인터페이스에서는 keyof 를 배열 구문에 넘겨주어 값들을 집합으로 만들 수 있다.

type Param = { key: string; value: number };
type A10 = Param[keyof Param]; // string | number

일반 경로와 토큰 나누기

토큰은 : 문자로 시작됩니다. 이를 통해 일반 경로에 대한건 무시하고, 토큰에 대한 문자열 타입만 필터해봅시다.

유니온에서 특정 규칙으로 원하는 값 찾기

이에 앞서 우리는 유니온에서 특정 규칙으로 원하는 값을 찾아야합니다. 예를 들어 아래 A11 유니온 타입에서 number 에 해당하는 타입만 가져오고 싶습니다.

type A11 = 'foo' | 1 | 'bar' | null | 10; 

type IsNumber<U> = U extends number ? U : never;

type A12 = IsNumber<A11>; // 1 | 10
type A13 = A11 extends number ? A11 : never; // never

방법은 간단합니다. 조건부 타입의 좌항에는 유니온을, 우항에는 해당 규칙을 작성합니다. 그럼 유니온에서 규칙에 해당하는 하지 않는 타입은 never 로 걸러지게 되고, 해당하는 타입만 반환할 수 있습니다. 이를 분산 조건부 타입이라고 합니다.

유니온 타입에서는 never 가 다른 타입과 함께 있다면 never를 무시합니다. 이를 통해 원하지 않는 값을 타입에서 제거하는 식으로 사용할 수 있습니다.

주의해야할 점은 이러한 형태로 필터링을 할 때는 A12 처럼 타입함수 형태로 만들어서 사용하지 않으면 A13 의 결과처럼 never 를 반환하게 됩니다. 이 때문에 타입스크립트에는 Extract 라는 이름으로 내장되어 있습니다.

type A11 = 'foo' | 1 | 'bar' | null | 10; 

type A12 = Extract<A11, number>; // 1 | 10

유니온에서 특정한 형태로 변형하기

하지만 Extract 에서는 결과에 대한 추론을 해서 새로운 값으로 바꿀 수 없기 때문에 map 타입 함수를 직접 구현해야합니다.

type FilterTokenPath<Path extends string> =
  Path extends `:${infer Token}` ? Token : never;

type A13 = FilterTokenPath<Split<'/home/:param1/:param2?', '/'>>;
// 'param1' | 'param2?'

그래서 / 로 나누어진 경로 요소 중에서 토큰에 해당하는 값만 필터하고, 앞에 있는 : 문자를 제거하는 타입 함수를 만들어 줍니다.

선택사항 규칙 적용하기

토큰 뒤에 ? 문자열이 붙을 경우 이를 선택사항(옵셔널)로 처리하고 싶습니다. 이제 부터는 단순히 문자열 유니온을 반환하는게 아니라 규칙에 대한 인터페이스를 구현해야합니다. 저는 { name: string; required: boolean; } 형태로 만들어, required 값을 통해 필수인지 아닌지를 적용하겠습니다.

type ParseTokenRequired<Token extends string> =
  Token extends `${infer Name}?`
    ? { name: Name; required: false }
    : { name: Token; required: true };

type GetParamsTokenFromPath<Path extends string> = 
  ParseTokenRequired<FilterTokenPath<Split<Path, '/'>>>

type A14 = GetParamsTokenFromPath<'/home/:param1/:param2?'>
// { name: 'param1', required: true } | { name: 'param2', requried: false }

토큰 규칙임을 증명했다면, 해당 토큰명을 포함한 규칙을 ParseTokenRequired 으로 넘겨서 ? 문자가 맨 끝에 있는지 확인하여, required 값을 판단합니다.

값 타입 추정하기

이제 :param2(foo|bar) 또는 :param3(\\d+) 와 같이 정해진 값이 있을 경우 해당 타입을 추정하려고 합니다. 만약 이러한 정해진 값이 없다면 값은 string이 되겠죠.

type ParseTokenValue<Value extends string> =
  Value extends '\\d+'
    ? number
    : Split<Value, '|'>[number];

type ParseTokenGroup<Token extends string> =
  Token extends `${infer Name}(${infer Group})`
    ? { name: Name; value: ParseTokenValue<Group> }
    : { name: Token; value: string; };

type ParseTokenRequired<Token extends string> =
  Token extends `${infer Name}?`
    ? { required: false } & ParseTokenGroup<Name>
    : { required: true } & ParseTokenGroup<Token>;

type GetParamsTokenFromPath<Path extends string> = 
  ParseTokenRequired<FilterTokenPath<Split<Path, '/'>>>

type A15 = GetParamsTokenFromPath<'/home/:param2(foo|bar)/:param3(\\d+)?'>
// | { name: 'param2', required: true, value: 'foo' | 'bar' } 
// | { name: 'param3', requried: false, value: number; }

ParseTokenRequired 는 이제 { required: boolean } 만 만들어주고, namevalueParseTokenGroup 에서 만들어서 합성하도록 했습니다.

ParseTokenValue 에서 일단 모든 정규식을 처리하기엔 많은 경우의 수가 있어서, 우선 가장 많이 사용하고 유일하게 사용하는 \\d+ 라는 규칙을 만나면 number 로 처리하게끔 했고, 그 외에는 | 문자로 나누어 정해진 문자 유니온을 가져오게끔 했습니다.

규칙 인터페이스를 매개변수 타입으로 만들기

type A13 = 
  | { name: 'param2', required: true, value: 'foo' | 'bar' }
  | { name: 'param3', requried: false, value: number }

type ParamsFromPath = ? // 이거를 구현해봅시다!
  
type A16 = ParamsFromPath<A13>; // { param2: 'foo' | 'bar'; param3?: number; }

우리는 GetParamsTokenFromPath 타입을 통해 { name: string; required: boolean; value: unknown } 형태의 규칙 인터페이스를 생성했습니다. 이제 이것을 매개변수에 쓰일 수 있는 형태의 타입으로 변환 하고자 합니다.

type ParamsFromPath<
  Path extends string,
  Tokens extends { name: string; required: boolean; value: unknown; } = GetParamsTokenFromPath<Path>
> = {
  [key in Tokens['name']]: string; // 여기에 Token key에 맞는 value를 넣어줘야 합니다.
}

첫번째 매개변수 제네릭으로 Path 문자열을 받고, 두 번째는 내부 타입에서 쓰기 위해 위에서 구현한 GetParamsTokenFromPath<Path> 의 결과를 Token 에 담습니다. 키는 Tokens['name'] 을 통해 토큰들에 있는 .name 집합에 접근합니다.

이렇게 기본적인 형태는 완성되었습니다. 이제 .value 로 값을 넣어주고, .required 로 옵셔널 여부를 만들어봅시다.

type ParamsFromPath<
  Path extends string,
  Tokens extends { name: string; required: boolean; value: unknown; } = GetParamsTokenFromPath<Path>
> = {
  [key in Tokens['name']]:
    Extract<Token, { name: key }>['required'] extends true
      ? Extract<Token, { name: key }>['value'];
      : undefined | Extract<Token, { name: key }>['value'];
}

type A17 = ParamsFromPath<'/home/:param2(foo|bar)/:param3(\\d+)?'>
// { param2: 'foo' | 'bar'; param3: number | undefined }

유니온에서 특정한 값을 찾아주는 Extract 을 통해 토큰 유니온에서 namekey 인 타입을 찾아서, value 의 타입을 객체의 값으로 사용하고, requiredfalse 이면 undefined 를 추가해줍니다.

이렇게 매개변수 타입이 완성된것 같지만, undefined 타입을 명시적으로 추가 해준 것이 사용성에 문제가 생길 수 있습니다. 다음 섹션에서 알아봅시다.

Optional 타입과 undefined 타입의 불일치 포함관계

타입스크립트에서 객체 타입이 전부 옵셔널인지 알 수 있는 방법이 뭐가 있을까요? 객체 타입의 모든 값을 옵셔널로 만드는 빌트인 타입 Partial 의 결과물과 원 타입의 포함관계를 통해 증명 할 수 있습니다.

type IsOptionalAll<O> = Partial<O> extends O ? 1 : 0;

type A18 = IsOptionalAll<{ foo: string }>; // 0
type A19 = IsOptionalAll<{ foo?: string }>; // 1
type A20 = IsOptionalAll<{ foo: string | undefined }>; // 0

여기서 주목해야할 것은 A19 의 결과입니다. 보통 옵셔널이라 함은 | undefined 를 추가한 유니온 값과 동일한데 이런 결과가 어떻게 나오는걸까요?

자바스크립트 런타임에서는 undefined 를 값으로 정의한 것과, 값을 아예 안넘기는 것에는 차이가 있습니다.

Object.keys({ a: undefined });
// >> [ "a" ]

위와 같이 undefined 를 명시적 값으로 넘겨준 객체에 Object.keys 를 사용하면 해당 키를 가져올 수 있죠. 그래서 타입스크립트에서 ? 를 통해 옵셔널을 정의하는것과 | undefined 타입을 통해 빈 값을 정의하는 것은 성격이 다릅니다. 정리하자면,

  • ? 를 통한 정의: 값을 안 넘겨줘도 된다.
  • | undefined 를 통한 정의: 값을 undefined 형태로라도 무조건 넘겨줘야 한다.

자 그러면 저희가 정의한 ParamsFromPath 의 옵셔널 구현을 변경해야합니다.

type ParamsFromPath<
  Path extends string,
  Tokens extends { name: string; required: boolean; value: unknown; } = GetParamsTokenFromPath<Path>
> = 
  & {
    [key in Extract<Token, { required: true }>['name']]: Extract<Token, { name: key }>['value'];
  }
  & {
    [key in Extract<Token, { required: false }>['name']]?: Extract<Token, { name: key }>['value'];
  };

type A21 = ParamsFromPath<'/home/:param2(foo|bar)/:param3(\\d+)?'>
// { param2: 'foo' | 'bar'; param3?: number }

required 의 값으로 키를 나누어, true 이면 일반적으로 선언하고, false 이면 옵셔널을 부여하는 2개의 객체 타입을 만들고, 이를 교차 타입으로 만들어줍니다. 이제 옵셔널까지 확실히 선언되었습니다.

함수 매개변수를 조건에 따라 옵셔널 부여하기

이제 매개변수에 사용할 타입은 완성이 되었습니다. 이제 이를 사용할 직렬화 함수를 구현해봅시다.

class URISchema<
  T extends string,
  P extends Record<string, unknown> = ParamsFromPath<T>
> {
  constructor (template: T) {}
  
  serialize(params: P): string {
    // 직렬화
  }
}

new URISchema('/:param1').serialize({ param1: 'foo' });
new URISchema('/:param1?').serialize(); // ❌ 타입 에러!
new URISchema('/:param1?').serialize({}); // ✅ 흠...

여기에 params 의 타입이 전부 옵셔널한 값이라면, 매개변수를 안 넘겨주도록 하고 싶습니다. 이를 타입으로 구현할려면, 옵셔널을 조건에 따라 넣어줄 수 있어야합니다.

위에서 객체타입에 옵셔널을 조건에 따라 넣어주기 위해서 2개의 객체를 따로 만들어서 교차하는 식으로 구현했습니다. 함수의 매개 변수에는 어떻게 이러한 옵셔널을 구현할 수 있을까요?

// type OptionalString = string?; <- 이런건 안됩니다.
type OptionalTuple = [string?];

const a1: OptionalTuple = ['1'];
const a2: OptionalTuple = [];

먼저 타입스크립트의 튜플은 요소 타입 뒤에 ? 를 붙임으로써 옵셔널을 구현할 수 있습니다. 첫 줄의 주석처럼 그냥 string? 으로 사용하면 오류가 나지만 튜플 요소에서만 사용할 수 있는 옵셔널 부여 방식입니다. 바로 이 특성을 이용하여 함수의 매개변수에 조건적으로 옵셔널을 줄 수 있습니다.

class URISchema<T extends string, P extends Record<string, unknown> = ParamsFromPath<T>> {
  constructor (template: T) {}
  
  serialize(...[params]: Partial<P> extends P ? [P?] : [P]): string {
    // 직렬화
  }
}

new URISchema('/:param1').serialize({ param1: 'foo' });
new URISchema('/:param1?').serialize(); // ✅ 타입 에러가 안 남! 편안~
new URISchema('/:param1?').serialize({}); // ✅ 이렇게도 써도 됌

매개변수는 전개 구문 연산자를 통해 튜플 형태로 타입을 지정해줄 수 있습니다. 이 점을 이용해서 첫번째 튜플 요소에 옵셔널 여부를 줄 수 있습니다.

명시적으로 매개변수 타입 넘겨주기

weverseshop://weverseshop.benx.co/?view=noticeDetail&artistId=3&shop=US&noticeId=5

저희 서비스에서 사용하는 URI중에서는 위와 같이 패스를 사용하지 않고 쿼리 스트링만으로 구성된 명세가 있습니다. 이 경우에는 쿼리 스트링에 대한 타입도 포함되어야 합니다. 그래서 패스 토큰에 포함되지 않는 키가 직렬화 될 때는 이를 쿼리 스트링 형태로 만들어주도록 했습니다.

class URISchema<T extends string, P extends Record<string, unknown> = ParamsFromPath<T>> {
  constructor (template: T) {}               
}

new URISchema('/:param1');
// T = "/:param1", P = { param1: string }

하지만 구현한 URISchema 클래스의 제네릭에는 첫번째 T 값으로 라우팅 경로에 대한 문자열 타입을 생성자로 부터 가져오고, 해당 T 값으로 P 타입에 문자열 타입로부터 추론된 매개변수 타입을 지정합니다. 여기에 명시적인 새로운 타입을 제네릭으로 받아온다면?

class URISchema<Q, T extends string = string, P extends Record<string, unknown> = ParamsFromPath<T>> {
  constructor (template: T) {}               
}

new URISchema<{ flag?: string }>('/:param1');
// Q = { flag?: string }, T = string, P = never

명시적으로 Q 제네릭을 넘겨주게 되면, 나머지 제네릭은 자동으로 추론할 수 없게 됩니다(T 값은 생성자에 넘겨준 문자열이 아닌 string 타입이 됩니다). 즉 명시적 제네릭과 자동 추론은 공존하기 힘듭니다.

그래서 (1) 명시적으로 첫번째 제네릭에 객체 타입을 넘길 경우랑, (2) 비명시적으로 패스 문자열에 있는 타입을 자동으로 추론하는 두 가지 오버로딩을 구현했습니다.

type Params = Record<string, string | number> | undefined;

interface URISchemaConstructor {
  new<T extends string, P extends Params = ParamsFromPath<T>>(template: T): URISchema<P>;
  new<P extends Params>(template: string): URISchema<P>;
}

interface URISchema<P extends Params> {
  serialize(...[params]: Partial<P> extends P ? [P?] : [P]): string;
}

export const URISchema: URISchemaConstructor = class {
  // 구현체
};

클래스 생성자를 오버로딩해주는 방식은 함수에 비해 까다로운데요, URISchemaConstructor 이라는 생성자 인터페이스를 만들어서 거기에서 각각의 생성자에 대한 제네릭을 정의합니다. 그리고 구현체인 URISchema 를 만들어서 해당 생성자 인터페이스에서 리턴 타입으로 지정하고, 실제 사용할 클래스 URISchema 에 해당 생성자 인터페이스를 타입으로 지정하고 클래스를 선언(declare)이 아닌 반환 형태로 이를 구현합니다.

// /home/foo/bar
new URISchema('/home/:param1/:param2').serialize({
  param1: 'foo',
  param2: 'bar'
});

// /home/search?keyword=foo
new URISchema<{ keyword: string }>('/home/search').serialize({
  keyword: 'foo'
});

이를 통해 제네릭을 비명시할 경우 생성자로 받아오는 문자열을 자동으로 추론할 수 있게 되고, 명시할 경우 해당 객체 타입을 사용하게끔 했습니다.

profile
위버스 컴퍼니에서 프론트엔드 개발자로 일하고 있습니다.

0개의 댓글