Typed Route Pattern Parser
Match Express-style route patterns and infer parameter names as a TypeScript union, including named wildcards and percent decoding.
Code
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("/", "/"));Report a problem with this exampleOpens a pre-filled issue on GitHub.