Skip to Content
TypeScript타입 추론 가능한 라우터 직접 만들어보기

타입 추론 가능한 라우터 직접 만들어보기

Tanstack Router를 살펴보면서 흥미가 생겨 기초적인 라우터 + 타입 추론을 직접 구현해보았습니다. vite를 기반으로 플러그인을 만들어 정해진 폴더 (/routes/)안에 있는 파일들을 라우터로 취급해 파일을 자동생성해 타입 추론이 가능합니다.

구현된 기능은 정말 일부라 온전하지 않고 대략적인 컨셉 이해를 위해서 간단한 url 이동과 타입 추론하는 기능만 만들었습니다.

타입 추론 미리보기

후술할 내용을 통해 만들진 결과를 먼저 보겠습니다

Params 타입 추론하기

예시로 페이지의 url slug가 /dashboard/[$id]/[$view]일 때, id를 Number로 파싱하고 view를 pie | bar로 파싱할 때 params 변수의 타입이 지정한 zod schema에 맞게 추론됩니다.

페이지 이동시에도 정해진 타입에 맞게 값을 넣을 수 있습니다.

Search 타입 추론하기

search의 경우도 동일하게 추론이 가능합니다. search로 show를 받아서 문자열을 boolean으로 파싱하는 스크립트를 작성하면, params와 동일하게 zod schema 타입에 따라 타입이 파싱되고

페이지 이동시에도 정해진 타입에 맞게 값을 넣을 수 있습니다.

그럼, 이제 하나씩 만들어보겠습니다.

파일 기반 라우터

Tanstack Router는 기본적으로 파일 기반으로 라우팅 하는걸 권장합니다. 이에 따라 저도 동일하게 폴더 기반으로 코드를 준비했습니다.

src routes dashboard [$id] index.tsx [$age].tsx [$view].tsx index.tsx about.tsx index.tsx

각 파일에는 createFileRouter라는 함수를 이용해 렌더링할 컴포넌트, 에러 컴포넌트, Parameters와 Search의 스키마 등을 지정해줍니다.

파일 이름 뿐 아니라 코드로도 선언하는 이유는, 모든 라우터 파일에서 선언된 Route 객체를 한 파일에 자동으로 모아서 다른 페이지 이동시 라우터의 타입을 추론하기 위함입니다.

export const Route = createFileRoute("/dashboard/[$id]/[$view]")({ component: Dashboard, errorComponent: () => <p>에러가 발생했습니다.</p>, params: { parse: (params) => ({ id: z.number().parse(Number(params.id)), // params 중 id는 숫자 view: z.union([z.literal("pie"), z.literal("bar")]).parse(params.view), // params 중 view는 pie 나 bar 중 하나 }), }, });

라우터 객체 자동으로 하나의 파일로 모으기

만들어진 라우터 객체들은 자동으로 한 파일로 모여집니다. Tanstack Router에서는 routeTree.gen.ts 라는 이름의 파일을 자동으로 만들어 모든 라우트 객체를 참조합니다.

다른 페이지로 이동 시 타입 추론을 위해서는 어떤 라우터가 있는지를 사전에 알 수 있어야 하기 때문에 이 파일은 필요합니다.

만들어진 파일은 아래 구조로 제작했습니다.

import { Route as DashboardIdViewRouteImport } from "./routes/dashboard/[$id]/[$view]"; // 모든 라우트 객체 import const DashboardIdViewRoute = { path: "/dashboard/[$id]/[$view]", component: DashboardIdViewRouteImport, } as const; // 라우팅 경로별로 컴포넌트와 매칭 export const routes = { // ... DashboardIdViewRoute, }; // 모든 라우터를 하나의 객체로 모으기 export type RouteValue = (typeof routes)[keyof typeof routes]; export type RoutePath = RouteValue["path"]; // 이동할 수 있는 모든 경로 추출 export type RoutesType = typeof routes; // 모든 라우터의 타입 추출

실제로는 라우터간 부모,자식 관계등도 포함되지만, 이번에는 중첩 라우팅은 구현하지 않아 제외했습니다.

위 구조를 통해 개발을 진행하는 시점에서, 이동할 수 있는 모든 경로와 각 경로별 라우터 (렌더링할 컴포넌트, 에러 컴포넌트, params와 search의 스키마와 타입)의 정보를 가질 수 있습니다.

위 코드는 개발자가 직접 작성하는게 아니라 자동으로 만들어지는 코드인데요, 이를 구현하기 위해서 vite의 커스텀 플러그인을 제작했습니다.

export type RouteNode = { name: string; path: string; componentPath: string; }; // 폴더의 경로를 따라 재귀적으로 실행되는 함수 export const generateRouteTree = ( dir: string, routePrefix = "", routeList: RouteNode[] = [] ): RouteNode[] => { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const isDir = entry.isDirectory(); const isPage = entry.isFile() && entry.name.endsWith(".tsx"); // ... 파일이나 폴더의 정보를 조회 if (isPage) { const isRootIndex = baseName === "index" && routePrefix === ""; const isNonIndexPage = baseName !== "index"; if (isRootIndex || isNonIndexPage) { routeList.push({ name: currentName, path: currentPath, componentPath: fullPath.replace(/^src[\\/]*/, ""), }); } } if (isDir) { // 폴더를 조회했다면 index.tsx 파일을 먼저 참조하고, 이후 폴더 하위 파일들에 접근 // ... if (hasIndexFile) { routeList.push({ name: dirName, path: dirPath, componentPath: indexFile.replace(/^src[\\/]*/, ""), }); } generateRouteTree(fullPath, dirPath, routeList); } } return routeList; }; const createGenTree = (routeList: RouteNode[]) => { // ... const tree = routeList.map((route) => { const importPath = toImportPath(route.componentPath); return `import { Route as ${route.name}Import } from "${importPath}";`; }); const importTree = routeList.map((route) => { return `const ${route.name} = { path: "${route.path}", component: ${route.name}Import, } as const;`; }); const routersCode = `export const routes = { ${routeList .map((v) => v.name) .join(", ")} };`; const typescriptUtilCode = ` export type RouteValue = (typeof routes)[keyof typeof routes]; export type RoutePath = RouteValue["path"]; export type RoutesType = typeof routes; `; // 생성된 타입스크립트 코드를 조립 const tsCode = tree.join("\n") + "\n" + importTree.join("\n") + "\n" + routersCode + "\n" + typescriptUtilCode; fs.writeFileSync("src/router.gen.ts", tsCode); };

요약하면 fs 모듈을 사용해 src/routes 디렉토리의 파일 및 폴더 구조를 탐색하여 URL 경로와 컴포넌트 파일 경로를 매핑하고 타입스크립트 코드로 만든 뒤, 코드를 router.gen.ts 파일에 저장합니다.

이 커스텀 플러그인을 사용하면 앞서 언급한 코드가 자동으로 만들어집니다.

라우터를 정의하는 createFileRoute

라우터 타입 추론을 위한 타입 유틸 함수들

함수 정의로 들어가기 전에, 타입 추론을 도와주는 몇가지 타입 유틸 함수를 먼저 보겠습니다.

문자열에서 필요한 키를 { [key: string]: string } 형태로 파싱하는 타입 유틸

  • infer _: [$...] 앞부분의 문자열을 추출하지만 변수 이름을 _로 하여 무시 (사용하지 않음.)

  • [$${infer Param}]: 문자열 내에서 정확히 [$파라미터명] 형식에 해당하는 부분을 추출

  • ${infer Rest}: 그 이후의 나머지 문자열을 Rest로 추출하고, 다시 재귀적으로 ExtractParamsFromPath를 호출해 변수가 여러개인 경우에도 타입을 추출할 수 있게 합니다.

type ExtractParamsFromPath<S extends string> = S extends `${infer _}[$${infer Param}]${infer Rest}` ? { [K in Param]: string } & ExtractParamsFromPath<Rest> : {}; type Extracted = ExtractParamsFromPath<"/dashboard/[$id]/[$view]">; // Extracted 는 아래 타입으로 추론됨. // { // id: string; // } & { // view: string; // }

params와 search 함수에서 필요한 타입 유틸

Route객체에는 param과 search를 파싱하는 함수가 들어있기 때문에, 각 함수들의 타입을 가져와서 반환 타입을 가져오면 useParams, useSearch 훅 에서 반환할 파싱된 데이터의 타입까지 추론할 수 있습니다.

  • P extends { parse: (input: any) => infer R } ? R : undefined: parse 함수가 parse(input: any): any 형태의 함수라면 parse 함수의 반환 타입을 R로 추론해서 리턴
type InferParsedParamsFromParams< P extends { parse: (input: any) => any } | undefined > = P extends { parse: (input: any) => infer R } ? R : undefined; type InferSearchType<T extends ((input: unknown) => any) | undefined> = T extends (input: unknown) => infer R ? R : undefined;

Path로 받은 경로에 따라서 라우터의 타입을 찾아 반환하는 타입 유틸

RouteByPath는 path를 제네릭으로 받고, 거기에 맞는 라우트 객체의 타입을 리턴합니다.

  • RoutesType는 자동으로 만들어지는 코드로, 라우트 객체를 전부 모은 routes 객체의 타입입니다.

이를 [K in keyof RoutesType]으로 각 라우트를 K라는 키로 순회합니다.

순회하는 라우터의 Path를 RoutesType[K]["path"]로 구하고 이를 P 타입과 비교해서 입력한 경로에 해당하는 라우터의 타입을 찾을 수 있습니다.

  • ValidateSearchReturn는 path를 제네릭으로 받고 validateSearch가 구현된 컴포넌트라면 그 반환 값을 추론하고, 아니라면 (=url에서 search로 받을 값이 없다면) undefined를 반환합니다.

RouteByPath<T>["component"] extends ...validateSearch 가 구현되어 있는 라우트인지 확인하고, 맞다면 validateSearch 함수의 반환 타입을 파싱합니다.

export type RouteByPath<P extends RoutePath> = { [K in keyof RoutesType]: RoutesType[K]["path"] extends P ? RoutesType[K] : never; }[keyof RoutesType]; export type ValidateSearchReturn<T extends RoutePath> = RouteByPath<T>["component"] extends { validateSearch: (...args: any[]) => any; } ? ReturnType<RouteByPath<T>["component"]["validateSearch"]> : undefined;

createFileRoute 함수 타입과 구현 살펴보기

위에서 설명한 것 처럼 createFileRoute 함수는 렌더링할 컴포넌트, 에러 발생 시 렌더링 할 컴포넌트, params 파싱, search 파싱 등, 각 페이지를 정의하기 위해 사용되는 함수입니다.

라우터의 옵션 타입

export type ParamsConfig< Path extends string, Parsed extends Record<string, any>, Stringified extends object = Parsed > = { parse: (params: ExtractParamsFromPath<Path>) => Parsed; stringify?: (data: Parsed) => Stringified; }; export type RouteOptions< Path extends string, Parsed extends Record<string, any>, Stringified extends object = Parsed, SearchFn extends ((input: unknown) => any) | undefined = undefined > = { component: ComponentType; errorComponent?: ComponentType; params?: ParamsConfig<Path, Parsed, Stringified>; validateSearch?: SearchFn; };

라우터 옵션에서 받는 제네릭은 4가지입니다.

  1. Path
  • parse 함수의 인자가 어떤 타입인지(/dashboard/[$id]/[$view]-> {id: string; view: string})를 추론하기 위해서 사용합니다. 즉, url을 기반으로 params 객체의 키 값을 알 수 있습니다.
  1. 타입 자동 추론을 위해 받는 제네릭 타입
  • Parsed는 params를 파싱해서 얻는 타입
  • Stringified는 params에서 데이터를 반환할 때 사용하는 파싱해서 얻는 타입
  • SearchFn는 search를 파싱해서 얻는 타입

이제 선언한 타입을 사용해 createFileRoute 함수를 만들어줍니다. RouteOptions 타입을 제네릭으로 받아 타입이 추론될 수 있도록 하고, 만들어둔 함수의 결과 값을 파싱하는 타입 유틸 함수(InferParsedParamsFromParams, InferSearchType)를 사용해 params, search의 파싱 결과 타입을 추론합니다.

export const createFileRoute = <Path extends string>(path: Path) => { return < Opt extends RouteOptions< Path, any, any, ((input: unknown) => any) | undefined > >( option: Opt ) => { type Params = InferParsedParamsFromParams<Opt["params"]>; type Search = InferSearchType<Opt["validateSearch"]>;

이제 라우터의 기능들을 정의할 차례입니다.

params, search 파싱하고 타입 지정하기

params와 search 모두 이미 완성된 타입이 있기 떄문에 정의된 파싱함수를 실행하고, 타입을 지정해줍니다.

const useParams = (): Params => { if (!option.params) return {} as Params; // extractParams는 url에 있는 변수를 객체 형태로 파싱하는 함수 // slug가 /dashboard/[$id] 일 때, url이 /dashboard/1 이라면 { id : 1 }로 파싱 const match = extractParams(path, window.location.pathname); return option.params.parse(match as ExtractParamsFromPath<Path>); }; const useSearch = (): Search => { if (!option.validateSearch) return {} as Search; const searchParams = Object.fromEntries( new URLSearchParams(window.location.search) ); return option.validateSearch(searchParams); };

다른 페이지로 이동하는 함수 만들기

다른 페이지로 이동하는 useNavigate는 “안정된 타입을 지원하는” 페이지 이동 훅입니다.

이미 프로젝트에 있는 모든 라우터의 타입을 알고 있기 때문에, 어떤 페이지로 이동하려면 어떤 값이 필요한지를 알고 있기 때문에 타입 자동완성이 중요합니다.

const useNavigate = () => <T extends RoutePath>(path: T) => { type ParamsType = ReturnType<RouteByPath<T>["component"]["useParams"]>; type SearchType = ValidateSearchReturn<T>; type Props = [ParamsType, SearchType] extends [undefined, undefined] ? void : ParamsType extends undefined ? { search: SearchType } : SearchType extends undefined ? { params: ParamsType } : { params: ParamsType; search: SearchType };
  • Props: params와 search는 라우터에서 사용하지 않는다면 undefined 일 수도 있습니다. 하지만 사용하지 않아도 타입을 추론할 때 undefined로 추론되어서 키 이름을 적어줘야 하는 불편함이 있어서, undefined 인 요소는 키를 제외할 수 있도록 작성했습니다. 즉, params만 있다면 params만 작성하고 search라는 키는 정의하지 않아도 되도록 합니다.
const interpolatePath = <ParamsType>( template: string, params?: ParamsType ): string => { if (!params) return template; return template.replace(/\[\$(\w+)\]/g, (_, key) => { const value = (params as any)[key]; if (value === undefined) { throw new Error(`Missing route param: "${key}"`); } return encodeURIComponent(String(value)); }); };
  • interpolatePath: slug (/dashboard/[$id]/[$view] 같은 형식) 에서[$param] 형식을 추출하고 이를 순회하면서 params(객체)의 값을 넣어 이동할 url을 만들어주는 함수입니다.
const serializeSearch = <SearchType>(search?: SearchType): string => { if (!search) return ""; const entries = Object.entries(search as any).filter( ([, v]) => v !== undefined ); if (entries.length === 0) return ""; const query = entries .map( ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}` ) .join("&"); return `?${query}`; };
  • serializeSearch: 파싱된 search 객체를 문자열로 만들어줍니다.
return (_props: Props) => { const props = _props as { params?: ParamsType; search?: SearchType }; const generatedPath = interpolatePath(path, props?.params); const generatedQuery = serializeSearch(props?.search); const url = `${generatedPath}${generatedQuery}`; if (window.location.pathname === url) return; window.history.pushState(null, "", url); window.dispatchEvent(new PopStateEvent("popstate")); }; };
  • navigate: 반환되는 함수는 이동할 라우터에 따른 params와 search를 받고, 이를 문자열로 조립해 url을 이동합니다. (이미 Path를 받는 커링된 함수라 navigate("/about")({ search: { show: true }}); 처럼 사용할 수 있습니다.)

url을 이동할 때 서버에 자원을 요청하지 않고 SPA로 관리하기 위해 window.history.pushState를 사용해 페이지 리로드 없이 주소창의 URL을 변경하고 히스토리 스택에 상태를 추가합니다.

이후 popstate 이벤트를 발생시켜 라우터 컴포넌트에서 URL 변경에 반응할 수 있도록 합니다.

페이지를 렌더링하는 라우터

라우터에서 처리할 일은 비교적 간단합니다.

  1. 페이지 이동 시 경로를 저장하는 상태를 이동된 url로 업데이트
  2. 라우터 중 현재 url과 매칭되는 라우터가 있는지 검사하고 있다면 컴포넌트를 렌더링 (에러 컴포넌트가 지정되어 있다면 에러 바운더리 처리)
const findMatchingRoute = (pathname: string) => Object.values(routes).find((route) => { // pathToRegex는 slug에 매칭되는 url을 찾을 수 있도록 slug를 정규식을 변환하는 함수 // 1. [$param]에 실제 값이 들어가는 경우를 찾는다. ([^/]+로 변환해 슬래시가 아닌 문자가 있는 경우를 탐지) // 2. 앞에 ^, 뒤에 $를 추가해 정확히 매칭되는 라우터만 찾는다 // slug가 /dashboard/[$id]일 때 "/dashboard/1"은 ✅, "/dashboard/1/pie"는 ❌ const regex = pathToRegex(route.path); return regex.test(pathname); }); export const Router: React.FC = () => { const [pathname, setPathname] = React.useState(window.location.pathname); React.useEffect(() => { const onPopState = () => setPathname(window.location.pathname); window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); }, []); const MatchedRoute = findMatchingRoute(pathname)?.component; if (!MatchedRoute) return <div>Not Found</div>; const hasFallback = !!MatchedRoute.errorComponent; if (hasFallback) return ( <ErrorBoundary FallbackComponent={MatchedRoute.errorComponent}> <MatchedRoute.component /> </ErrorBoundary> ); return <MatchedRoute.component />; };
Last updated on