Typed Route Pattern Parser

Match Express-style route patterns and infer parameter names as a TypeScript union, including named wildcards and percent decoding.

TypescriptData Fetching

Code

snippet
type ExtractSegment<Segment extends string> =
  Segment extends `${infer Name}/${infer Tail}`
    ? Name | RouteParams<`/${Tail}`>
    : Segment;

export type RouteParams<Pattern extends string> =
  Pattern extends `${string}:${infer Rest}`
    ? ExtractSegment<Rest>
    : Pattern extends `${string}*${infer Rest}`
      ? ExtractSegment<Rest>
      : never;

export function matchRoute<Pattern extends string>(
  pattern: Pattern,
  pathname: string,
): Record<RouteParams<Pattern>, string> | null {
  if (typeof pattern !== "string" || !pattern.startsWith("/")) {
    throw new TypeError("pattern must start with /");
  }
  if (typeof pathname !== "string") throw new TypeError("pathname must be a string");
  const compiled = compile(pattern);
  const match = compiled.regex.exec(pathname);
  if (match === null) return null;
  const params = {} as Record<RouteParams<Pattern>, string>;
  compiled.names.forEach((name, index) => {
    const value = match[index + 1];
    if (value === undefined) return;
    try {
      (params as Record<string, string>)[name] = decodeURIComponent(value);
    } catch {
      throw new Error(`invalid percent-encoding for "${name}"`);
    }
  });
  return params;
}

interface CompiledPattern {
  readonly regex: RegExp;
  readonly names: string[];
}

function compile(pattern: string): CompiledPattern {
  if (pattern === "/") return { regex: /^\/$/, names: [] };
  const names: string[] = [];
  const segments = pattern.split("/").slice(1);
  const expressions = segments.map((segment) => {
    if (segment === "") return "";
    const parameter = /^[:*]/.test(segment);
    if (!parameter) return escapeRegExp(segment);
    const name = segment.slice(1);
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
      throw new Error(`invalid parameter segment "${segment}"`);
    }
    if (names.includes(name)) throw new Error(`duplicate parameter "${name}"`);
    names.push(name);
    return segment.startsWith("*") ? "(.*)" : "([^/]+)";
  });
  return { regex: new RegExp(`^/${expressions.join("/")}/?$`), names };
}

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

const userPost = matchRoute("/users/:userId/posts/:postId", "/users/42/posts/hello%20world");
console.log(userPost);
console.log(matchRoute("/users/:userId", "/users/42/extra"));
const file = matchRoute("/files/*path", "/files/reports/2026/q3.pdf");
console.log(file);
console.log(matchRoute("/", "/"));