Template Literal Types로 Type-safe하게 express 라우터 작성하기

2022년 1월 5일

주의 ⛔️

  • 이 글이 작성된지 2년이 넘었어요.
  • 누구나 숨기고 싶은 흑역사가 있답니다.

Template Literal Types?

타입스크립트 4.1부터 Template Literal Types라는 기능이 생겼습니다.

쉽게 말하면, 자바스크립트의 Template Literal 문법과 타입스크립트의 String Literal Type이 합쳐진 기능입니다.

// Template Literal
const name = "장호승";
const str = `안녕하세요 제 이름은 ${name}입니다.`;

// String Literal Type
type Fruit = "apple" | "banana";

위의 두 가지가 합쳐지면 어떤 일이 일어날까요?

type FirstName = "호승";
type Name = `${FirstName}`; // "장호승"

String Literal Type을 Template Literal 문법으로 매핑시켜서 새로운 String Literal Type을 정의할 수 있습니다.

Typescript Playground

Union도 사용 가능합니다.

type FirstName = "호승" | "래원";
type Name = `${FirstName}`; // "장호승" | "장래원"

Typescript Playground

또한 infer 키워드도 활용할 수 있습니다.

type ExtractFirstName<T extends string> = T extends `${infer FirstName}` ? FirstName : never;
type FirstName = ExtractFirstName<"장호승">; // "호승"

Typescript Playground

간단한 예시

아주 간단한 예시를 들어보겠습니다. 이벤트와 그에 대응하는 이벤트 핸들러의 타입을 정의한다고 가정해봅시다.

그러면 아마 아래와 같이 코드를 작성할 것 같습니다.

type EventName = "click" | "change" | "focus";

type EventHandlers = {
  onClick: () => void;
  onChange: () => void;
  onFocus: () => void;
};

위 코드의 문제점이 뭘까요? Event가 추가될 때마다 EventHanlders에 새로운 핸들러의 정의도 계속 추가해줘야 한다는 점입니다. 실수하기가 매우 좋은 환경입니다.

type EventName = "click" | "change" | "focus" | "blur";

type EventHandlers = {
  onClick: () => void;
  onChange: () => void;
  onFocus: () => void;
  onBlur: () => void;
};

여기서 Template Literal Type을 사용하면, 이 문제를 해결할 수 있습니다.

type EventName = "click" | "change" | "focus" | "blur";

/*
{
  onClick: () => void;
  onChange: () => void;
  onFocus: () => void;
  onBlur: () => void;
}
*/
type EventHandlers = {
  [key in `on${Capitalize<EventName>}`]: () => void;
};

Typescript Playground

이제 Event만 추가해줘도 EventHandlers의 정의는 알아서 수정됩니다.

Type-safe하게 라우터 작성하기

이 글에서 언급할 express의 문제는, path parameters가 req.params의 타입에 반영되지 않는 문제입니다.

router.get("/users/:userId", async (req, res) => {
  /* ... */
});

위처럼 userId라는 parameter를 정의하더라도 req.params의 타입에는 적용되지 않습니다.

이 문제를 Template Literal Types로 해결해봅시다.

1. Path Parameters 추출

위에서 보여드렸던 3항 연산자와 infer 키워드를 사용해 path parameter의 이름들을 추출할 수 있습니다. 그걸 object의 keys로써 정의하여 req.params의 타입을 만들어낼 수 있습니다.

type ExtractPathParameters<T extends string> = T extends `${any}:${infer ParameterName}/${infer Suffix}`
  ? { [key in ParameterName | keyof ExtractPathParameters<Suffix>]: string }
  : T extends `${any}:${infer ParameterName}`
  ? { [key in ParameterName]: string }
  : {};

/*
{
  userId: string;
  postId: string;
}
*/
type PathParams = ExtractPathParameters<"/users/:userId/posts/:postId">;

Typescript Playground

2. Router wrapper 만들기

이제 간단하게 express router wrapper를 하나 만들어서 문제를 해결해봅시다.

@types/express에서 RequestHandler의 정의를 보시면 첫 번째 generic으로 req.params의 타입을 넘겨줄 수 있는데, 이 때 위에서 만든 ExtractPathParameters를 활용하면 됩니다.

위에서 만든 ExtractPathParameters와 Generic 문법을 사용해서 HTTP GET 라우터의 wrapper를 하나 만들어 보겠습니다.

import { RequestHandler } from "express";

type ExtractPathParameters<T extends string> = T extends `${any}:${infer ParameterName}/${infer Suffix}`
  ? { [key in ParameterName | keyof ExtractPathParameters<Suffix>]: string }
  : T extends `${any}:${infer ParameterName}`
  ? { [key in ParameterName]: string }
  : {};

function GET<P extends string>(path: P, handler: RequestHandler<ExtractPathParameters<P>>) {
  return Router().get(path, handler);
}

GET("/users/:userId", (req, res) => {
  req.params; // { userId: string }
});

GET function이 Generic 변수 P로 첫 번째 인자인 path의 값을 추론해서 RequestHandler에 넘겨주고 있습니다.

이제 express에서 path parameters를 Type-safe하게 사용할 수 있습니다.

마무리

2021년 회고에서 선언한 올해 목표 중 하나가 컴포트 존인 타입스크립트 생태계를 벗어나 보는 거였는데, 가면 갈수록 타입스크립트의 강력한 추론 기능의 매력에서 헤어나오질 못하겠네요.. 큰일입니다.

최근에 express를 type-safe하게 사용하기 위한 오픈소스인 typed-express를 개발해서 배포했었는데, 거기에도 1.1.0 릴리즈에 Template Literal Types를 사용한 path parameters 추론 기능을 추가해줬습니다.

타입스크립트가 또 어떤 기능을 내놓을지 앞으로도 기대되네요.