typestar

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

  1. A template literal type parses the pattern into a params object.
  2. The handler's params argument is typed from the route it registers.
  3. A wrong parameter name fails to compile, not at run time.

Keywords and builtins used here

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.

Type this snippet

Step 1 of 1 in Encore, step 12 of 12 in Type-level programming.

← Previous