typed-router.ts in TypeScript
A router whose path parameters are extracted from the pattern at compile time.
type Segments<Path extends string> =
Path extends `${infer Head}/${infer Rest}`
? Head | Segments<Rest>
: Path;
type ParamName<Segment extends string> =
Segment extends `{${infer Name}}` ? Name : never;
type Params<Path extends string> = {
[K in ParamName<Segments<Path>>]: string;
};
type Method = "GET" | "POST" | "DELETE";
interface Request<Path extends string> {
method: Method;
path: string;
params: Params<Path>;
}
type Handler<Path extends string, Result> =
(request: Request<Path>) => Result | Promise<Result>;
interface Route {
method: Method;
pattern: string;
handler: Handler<string, unknown>;
}
class Router {
#routes: Route[] = [];
on<Path extends string, Result>(
method: Method,
pattern: Path,
handler: Handler<Path, Result>,
): this {
this.#routes.push({
method,
pattern,
handler: handler as Handler<string, unknown>,
});
return this;
}
async handle(method: Method, path: string): Promise<unknown> {
for (const route of this.#routes) {
if (route.method !== method) continue;
const params = match(route.pattern, path);
if (!params) continue;
return route.handler({ method, path, params });
}
return { status: 404 };
}
}
function match(pattern: string, path: string): Record<string, string> | null {
const wanted = pattern.split("/");
const given = path.split("/");
if (wanted.length !== given.length) return null;
const params: Record<string, string> = {};
for (const [index, part] of wanted.entries()) {
const actual = given[index] ?? "";
if (part.startsWith("{") && part.endsWith("}")) {
params[part.slice(1, -1)] = actual;
} else if (part !== actual) {
return null;
}
}
return params;
}
const router = new Router()
.on("GET", "/healthz", () => ({ status: "ok" }))
.on("GET", "/tours/{lang}/{tour}", ({ params }) => ({
lang: params.lang,
tour: params.tour,
}))
.on("DELETE", "/tours/{lang}", ({ params }) => ({ deleted: params.lang }));
// .on("GET", "/tours/{lang}", ({ params }) => params.slug) // no such param
const results = await Promise.all([
router.handle("GET", "/healthz"),
router.handle("GET", "/tours/ts/generics"),
router.handle("DELETE", "/tours/go"),
router.handle("GET", "/nope"),
]);
console.log(results);
How it works
- A template literal type parses the pattern into a params object.
- The handler's params argument is typed from the route it registers.
- A wrong parameter name fails to compile, not at run time.
Keywords and builtins used here
HandlerMethodParamsPathPromiseRecordRequestasasyncawaitclassconstcontinueelseextendsforfunctionhandlerifinterfaceneverofreturnstringthistype
The run, in numbers
- Lines
- 90
- Characters to type
- 2153
- Tokens
- 581
- Three-star pace
- 115 tpm
At the three-star pace of 115 tokens a minute, this run takes about 303 seconds.
Step 1 of 1 in Encore, step 12 of 12 in Type-level programming.