Skip to content

Instantly share code, notes, and snippets.

@jussi-kalliokoski
Created March 21, 2024 08:35
Show Gist options
  • Save jussi-kalliokoski/374f7c0273979abaa82ed14f70e420ab to your computer and use it in GitHub Desktop.
Save jussi-kalliokoski/374f7c0273979abaa82ed14f70e420ab to your computer and use it in GitHub Desktop.
A fully typed route parser.
// Usage:
// let parseRoute = routeParser("/users/{userId}/posts/{postId}");
// let params = parseRoute("/users/123/posts/456");
// console.log(params); // { userId: "123", postId: "456" }
const KIND_KEY = "key" as const;
const KIND_VALUE = "value" as const;
/**
* Create a route parser from a route string.
* @template {string} Route - A string literal type representing the route.
* @param {Route} route - The route string.
* @returns {RouteParser<Route>} - A function that takes a path string and returns the route parameters or null if the path does not match the route.
*/
export function routeParser<Route extends string>(route: Route): RouteParser<Route> {
const parts = routeParts(route);
return ((path) => {
const result: Record<string, string> = {};
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.kind === KIND_KEY) {
if (i === parts.length - 1) {
result[part.value] = path;
return result;
}
const nextPart = parts[i + 1];
const nextPartStart = path.indexOf(nextPart.value);
if (nextPartStart === -1) {
return null;
}
result[part.value] = path.slice(0, nextPartStart);
path = path.slice(nextPartStart);
} else {
const { value } = part;
if (!path.startsWith(value)) {
return null;
}
path = path.slice(value.length);
}
}
return result;
}) as RouteParser<Route>;
};
function routeParts(route: string): Array<RoutePart> {
const parts = [];
while (route.length >= 0) {
if (route.startsWith("{")) {
route = route.slice(1);
const end = route.indexOf("}");
parts.push({ kind: KIND_KEY, value: route.slice(0, end) });
route = route.slice(end + 1);
if (route.startsWith("{")) {
throw new Error("Invalid route: multiple parameters in a row");
}
} else {
const end = route.indexOf("{");
if (end === -1) {
parts.push({ kind: KIND_VALUE, value: route });
break;
}
parts.push({ kind: KIND_VALUE, value: route.slice(0, end) });
route = route.slice(end);
}
}
return parts;
}
type RouteParser<Route extends string> =
Route extends `${string}}{${string}`
? never
: (path: string) => (RouteParams<Route> | null);
type RoutePart =
| { kind: typeof KIND_KEY, value: string }
| { kind: typeof KIND_VALUE, value: string };
type RouteParams<Route extends string> = Record<RouteKeys<Route>[number], string>;
type RouteKeys<Route extends string, Parts extends Array<string> = []> =
Route extends `{${infer Rest}`
? RouteKeys<RouteKeysExtractRest<Rest>, [...Parts, RouteKeysExtractKey<Rest>]>
: Route extends `${infer Head}{${infer Rest}`
? RouteKeys<RouteKeysExtractRest<Rest>, [...RouteKeys<Head, Parts>, RouteKeysExtractKey<Rest>]>
: Parts;
type RouteKeysExtractRest<Route extends string> =
Route extends `${string}}${infer Rest}`
? Rest
: never;
type RouteKeysExtractKey<Route extends string> =
Route extends `${infer Key}}${string}`
? Key
: never;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment