타입 안전한 라우터는 /users/:id/posts/:postId 같은 경로 패턴에서 :id:postId를 자동으로 추출 해서 타입으로 만듭니다.

문제: 일반적인 라우터

TYPESCRIPT
// 타입 없는 라우터
router.get('/users/:id', (req) => {
  const id = req.params.id; // string — 어떤 params가 있는지 모름
  const typo = req.params.idd; // ⚠️ 오타인데 에러 없음
});

경로에서 매개변수 추출

Template Literal Types와 infer로 경로 문자열에서 매개변수를 추출합니다.

TYPESCRIPT
// ':param'을 찾아서 추출
type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type Params1 = ExtractParams<'/users/:id'>;
// 'id'

type Params2 = ExtractParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'

type Params3 = ExtractParams<'/about'>;
// never (매개변수 없음)

// 매개변수를 Record로 변환
type RouteParams<Path extends string> =
  ExtractParams<Path> extends never
    ? {}
    : Record<ExtractParams<Path>, string>;

type P1 = RouteParams<'/users/:id'>;
// { id: string }

type P2 = RouteParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }

타입 안전한 라우터 구현

TYPESCRIPT
type Handler<Path extends string> = (
  req: {
    params: RouteParams<Path>;
    query: Record<string, string>;
  },
  res: {
    json: (data: unknown) => void;
    status: (code: number) => { json: (data: unknown) => void };
  }
) => void;

class TypedRouter {
  private routes: Map<string, Function> = new Map();

  get<Path extends string>(path: Path, handler: Handler<Path>): this {
    this.routes.set(`GET ${path}`, handler);
    return this;
  }

  post<Path extends string>(path: Path, handler: Handler<Path>): this {
    this.routes.set(`POST ${path}`, handler);
    return this;
  }
}

const router = new TypedRouter();

// ✅ params가 자동 추론됨
router.get('/users/:id', (req, res) => {
  const { id } = req.params; // id: string — 자동 완성됨
  res.json({ userId: id });
});

router.get('/users/:userId/posts/:postId', (req, res) => {
  const { userId, postId } = req.params; // 둘 다 자동 완성
  res.json({ userId, postId });
});

// ❌ 존재하지 않는 매개변수 접근 시 에러
router.get('/users/:id', (req, res) => {
  // const name = req.params.name; // ❌ Error — name은 없음
});

타입 안전한 링크 생성

TYPESCRIPT
// 라우트 정의
type Routes = {
  '/': {};
  '/users': {};
  '/users/:id': { id: string };
  '/users/:id/posts/:postId': { id: string; postId: string };
};

// 매개변수를 치환해서 실제 URL 생성
type BuildPath<
  Path extends string,
  Params extends Record<string, string>
> = Path extends `${infer Before}:${infer Param}/${infer After}`
  ? Param extends keyof Params
    ? `${Before}${Params[Param]}/${BuildPath<After, Params>}`
    : never
  : Path extends `${infer Before}:${infer Param}`
    ? Param extends keyof Params
      ? `${Before}${Params[Param]}`
      : never
    : Path;

// 타입 안전한 링크 함수
function buildLink<Path extends keyof Routes>(
  path: Path,
  ...args: Routes[Path] extends Record<string, never>
    ? []
    : [params: Routes[Path]]
): string {
  let result: string = path;
  if (args.length > 0) {
    const params = args[0] as Record<string, string>;
    for (const [key, value] of Object.entries(params)) {
      result = result.replace(`:${key}`, value);
    }
  }
  return result;
}

// 사용
buildLink('/');                                    // '/'
buildLink('/users/:id', { id: '42' });              // '/users/42'
buildLink('/users/:id/posts/:postId', { id: '1', postId: '99' }); // '/users/1/posts/99'
// buildLink('/users/:id');                         // ❌ Error — params 누락
// buildLink('/users/:id', { name: 'test' });       // ❌ Error — 잘못된 params

Next.js에서의 활용

TYPESCRIPT
// Next.js App Router의 경로를 타입으로 관리
type AppRoutes = {
  '/': {};
  '/about': {};
  '/blog/[slug]': { slug: string };
  '/users/[id]/settings': { id: string };
};

// 타입 안전한 Link 컴포넌트 래퍼를 만들 수 있음

정리

  • Template Literal Types + infer로 라우트 경로에서 매개변수를 자동 추출한다
  • 경로별 params 타입이 자동 추론되어 오타를 컴파일 타임에 잡는다
  • 링크 생성 함수도 타입 안전하게 만들 수 있다
  • tanstack/router, Hono 등이 이 패턴을 활용하는 대표적인 라이브러리다
댓글 로딩 중...