From a4cd1a55b54e3a9fef42d0029f205fd0707f3ebf Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 9 May 2026 01:56:40 +0100 Subject: [PATCH 1/3] feat: add typescript types --- index.d.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/route.d.ts | 34 ++++++++++++++++++++++++++++++++++ package.json | 4 +++- 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 index.d.ts create mode 100644 lib/route.d.ts diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..2dfe4f4 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,49 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { + NextFunction, + RequestHandler, + Handlers, + Route as IRoute +} from './lib/route' +import Route = require('./lib/route') + +export type Path = string | RegExp | Array + +interface RouterOptions { + caseSensitive?: boolean + mergeParams?: boolean + strict?: boolean +} + +interface Router extends RequestHandler { + params: Record + stack: any[] + caseSensitive: boolean + mergeParams: boolean + strict: boolean + + handle(req: IncomingMessage, res: ServerResponse, callback: NextFunction): void + param(name: string, fn: (req: any, res: any, next: NextFunction, value: any, name: string) => void): this + route(path: Path): IRoute + + use(path: Path, ...handlers: Handlers[]): this + use(...handlers: Handlers[]): this + + all(path: Path, ...handlers: Handlers[]): this + get(path: Path, ...handlers: Handlers[]): this + post(path: Path, ...handlers: Handlers[]): this + put(path: Path, ...handlers: Handlers[]): this + delete(path: Path, ...handlers: Handlers[]): this + patch(path: Path, ...handlers: Handlers[]): this + options(path: Path, ...handlers: Handlers[]): this + head(path: Path, ...handlers: Handlers[]): this +} + +interface RouterConstructor { + (options?: RouterOptions): Router + new (options?: RouterOptions): Router + Route: typeof Route +} + +declare const router: RouterConstructor +export = router diff --git a/lib/route.d.ts b/lib/route.d.ts new file mode 100644 index 0000000..b0ba1ab --- /dev/null +++ b/lib/route.d.ts @@ -0,0 +1,34 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' + +export type NextFunction = (err?: any) => void +export type RequestHandler = (req: any, res: any, next: NextFunction) => void +export type ErrorRequestHandler = (err: any, req: any, res: any, next: NextFunction) => void +export type Handler = RequestHandler | ErrorRequestHandler +export type Handlers = Handler | Handlers[] + +interface Route { + path: string + stack: any[] + methods: Record + + _handlesMethod(method: string): boolean + _methods(): string[] + dispatch(req: IncomingMessage, res: ServerResponse, done: NextFunction): void + + all(...handlers: Handlers[]): this + get(...handlers: Handlers[]): this + post(...handlers: Handlers[]): this + put(...handlers: Handlers[]): this + delete(...handlers: Handlers[]): this + patch(...handlers: Handlers[]): this + options(...handlers: Handlers[]): this + head(...handlers: Handlers[]): this +} + +interface RouteConstructor { + (path: string): Route + new (path: string): Route +} + +declare const Route: RouteConstructor +export = Route diff --git a/package.json b/package.json index e6be557..12ad339 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "path-to-regexp": "^8.0.0" }, "devDependencies": { + "@types/node": "18.19.130", "finalhandler": "^2.1.0", "mocha": "10.2.0", "nyc": "15.1.0", @@ -32,7 +33,8 @@ "LICENSE", "HISTORY.md", "README.md", - "index.js" + "index.js", + "index.d.ts" ], "engines": { "node": ">= 18" From 6adfca5f013c59ea9834a36dc972b60a8dbb9108 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 13 Jul 2026 11:33:48 +0100 Subject: [PATCH 2/3] add tests --- .github/workflows/ci.yml | 3 +++ .gitignore | 1 + index.d.ts | 36 ++++++++++++++++++++---------------- lib/route.d.ts | 27 +++++++++++++++------------ package.json | 9 ++++++++- test/types.ts | 25 +++++++++++++++++++++++++ tsconfig.json | 7 +++++++ 7 files changed, 79 insertions(+), 29 deletions(-) create mode 100644 test/types.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dffb14f..08d4e17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: - name: Lint code run: npm run lint + - name: Check types + run: npm run test-types + - name: Upload code coverage uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.gitignore b/.gitignore index f15b98e..282eaee 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ coverage/ node_modules/ npm-debug.log package-lock.json +*.lock diff --git a/index.d.ts b/index.d.ts index 2dfe4f4..204296c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,12 +1,14 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -import type { - NextFunction, - RequestHandler, - Handlers, - Route as IRoute +import type { + Handler, + NextFunction, + RequestHandler, + Route as IRoute } from './lib/route' import Route = require('./lib/route') +export type RouterMethod = (path: Path, handler: F) => T + export type Path = string | RegExp | Array interface RouterOptions { @@ -26,17 +28,19 @@ interface Router extends RequestHandler { param(name: string, fn: (req: any, res: any, next: NextFunction, value: any, name: string) => void): this route(path: Path): IRoute - use(path: Path, ...handlers: Handlers[]): this - use(...handlers: Handlers[]): this - - all(path: Path, ...handlers: Handlers[]): this - get(path: Path, ...handlers: Handlers[]): this - post(path: Path, ...handlers: Handlers[]): this - put(path: Path, ...handlers: Handlers[]): this - delete(path: Path, ...handlers: Handlers[]): this - patch(path: Path, ...handlers: Handlers[]): this - options(path: Path, ...handlers: Handlers[]): this - head(path: Path, ...handlers: Handlers[]): this + use: { + (path: Path, handler: F): Router + (handler: F): Router + } + + all: RouterMethod + get: RouterMethod + post: RouterMethod + put: RouterMethod + delete: RouterMethod + patch: RouterMethod + options: RouterMethod + head: RouterMethod } interface RouterConstructor { diff --git a/lib/route.d.ts b/lib/route.d.ts index b0ba1ab..322383f 100644 --- a/lib/route.d.ts +++ b/lib/route.d.ts @@ -1,12 +1,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http' export type NextFunction = (err?: any) => void -export type RequestHandler = (req: any, res: any, next: NextFunction) => void -export type ErrorRequestHandler = (err: any, req: any, res: any, next: NextFunction) => void + +export type RequestHandler = (req: IncomingMessage, res: ServerResponse, next: NextFunction) => void +export type ErrorRequestHandler = (err: Error, req: IncomingMessage, res: ServerResponse, next: NextFunction) => void + export type Handler = RequestHandler | ErrorRequestHandler -export type Handlers = Handler | Handlers[] -interface Route { +export type RouteMethod = (handler: F) => T + +export interface Route { path: string stack: any[] methods: Record @@ -15,14 +18,14 @@ interface Route { _methods(): string[] dispatch(req: IncomingMessage, res: ServerResponse, done: NextFunction): void - all(...handlers: Handlers[]): this - get(...handlers: Handlers[]): this - post(...handlers: Handlers[]): this - put(...handlers: Handlers[]): this - delete(...handlers: Handlers[]): this - patch(...handlers: Handlers[]): this - options(...handlers: Handlers[]): this - head(...handlers: Handlers[]): this + all: RouteMethod + get: RouteMethod + post: RouteMethod + put: RouteMethod + delete: RouteMethod + patch: RouteMethod + options: RouteMethod + head: RouteMethod } interface RouteConstructor { diff --git a/package.json b/package.json index 12ad339..a75c80b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ ], "license": "MIT", "repository": "pillarjs/router", + "type": "commonjs", + "main": "index.js", + "types": "index.d.ts", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -20,8 +23,11 @@ "path-to-regexp": "^8.0.0" }, "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@tsconfig/node18": "18.2.6", "@types/node": "18.19.130", "finalhandler": "^2.1.0", + "expect-type": "1.4.0", "mocha": "10.2.0", "nyc": "15.1.0", "run-series": "^1.1.9", @@ -45,6 +51,7 @@ "test:debug": "mocha --reporter spec --check-leaks test/ --inspect --inspect-brk", "test-ci": "nyc --reporter=lcov --reporter=text npm test", "test-cov": "nyc --reporter=text npm test", - "version": "node scripts/version-history.js && git add HISTORY.md" + "version": "node scripts/version-history.js && git add HISTORY.md", + "test-types": "tsc --noEmit && attw --pack" } } diff --git a/test/types.ts b/test/types.ts new file mode 100644 index 0000000..ebcc7a2 --- /dev/null +++ b/test/types.ts @@ -0,0 +1,25 @@ +import { IncomingMessage, ServerResponse } from "node:http"; +import { expectTypeOf } from "expect-type"; +import Router from ".."; +import { ErrorRequestHandler } from "../lib/route"; + +const router = Router(); + +router.get("/", function (req, res) { + expectTypeOf(req).toEqualTypeOf(); + expectTypeOf(res).toEqualTypeOf(); +}); + +router.get("/", function (req, res, next) { + expectTypeOf(req).toEqualTypeOf(); + expectTypeOf(res).toEqualTypeOf(); + expectTypeOf(next).toBeFunction(); +}); + +// typescript has no way to infer the types automatically +router.get("/", function (err, req, res, next) { + expectTypeOf(err).toEqualTypeOf(); + expectTypeOf(req).toEqualTypeOf(); + expectTypeOf(res).toEqualTypeOf(); + expectTypeOf(next).toBeFunction(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..369ea4e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@tsconfig/node18/tsconfig.json", + "include": ["index.d.ts", "lib/route.d.ts", "test/types.ts"], + "compilerOptions": { + "types": ["node"] + } +} From e54b5766269d044a4c9b40eddee9e86e279c0156 Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 15 Jul 2026 12:47:36 +0100 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Sebastian Beltran --- index.d.ts | 10 +++--- lib/route.d.ts | 29 ++++++++------- test/types.ts | 97 ++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 117 insertions(+), 19 deletions(-) diff --git a/index.d.ts b/index.d.ts index 204296c..b01689b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -11,13 +11,15 @@ export type RouterMethod = (path: Path, h export type Path = string | RegExp | Array -interface RouterOptions { +export interface RouterOptions { caseSensitive?: boolean mergeParams?: boolean strict?: boolean } -interface Router extends RequestHandler { +type MethodHandlers = { [M in Method | 'all']: RouterMethod } + +export interface Router extends RequestHandler, MethodHandlers { params: Record stack: any[] caseSensitive: boolean @@ -29,8 +31,8 @@ interface Router extends RequestHandler { route(path: Path): IRoute use: { - (path: Path, handler: F): Router - (handler: F): Router + (path: Path, handler: HandlerArg, ...handlers: Array>): Router + (handler: HandlerArg, ...handlers: Array>): Router } all: RouterMethod diff --git a/lib/route.d.ts b/lib/route.d.ts index 322383f..56f6275 100644 --- a/lib/route.d.ts +++ b/lib/route.d.ts @@ -7,9 +7,22 @@ export type ErrorRequestHandler = (err: Error, req: IncomingMessage, res: Server export type Handler = RequestHandler | ErrorRequestHandler -export type RouteMethod = (handler: F) => T +export type HandlerArg = F | ReadonlyArray> -export interface Route { +export type RouteMethod = (handler: HandlerArg, ...handlers: Array>) => T + + +export type Method = + | 'acl' | 'bind' | 'checkout' | 'connect' | 'copy' | 'delete' | 'get' + | 'head' | 'link' | 'lock' | 'm-search' | 'merge' | 'mkactivity' + | 'mkcalendar' | 'mkcol' | 'move' | 'notify' | 'options' | 'patch' + | 'post' | 'propfind' | 'proppatch' | 'purge' | 'put' | 'query' + | 'rebind' | 'report' | 'search' | 'source' | 'subscribe' | 'trace' + | 'unbind' | 'unlink' | 'unlock' | 'unsubscribe' + +export type MethodHandlers = { [M in Method | 'all']: RouteMethod } + +export interface Route extends MethodHandlers { path: string stack: any[] methods: Record @@ -17,20 +30,10 @@ export interface Route { _handlesMethod(method: string): boolean _methods(): string[] dispatch(req: IncomingMessage, res: ServerResponse, done: NextFunction): void - - all: RouteMethod - get: RouteMethod - post: RouteMethod - put: RouteMethod - delete: RouteMethod - patch: RouteMethod - options: RouteMethod - head: RouteMethod } interface RouteConstructor { - (path: string): Route - new (path: string): Route + new (path: Path): Route } declare const Route: RouteConstructor diff --git a/test/types.ts b/test/types.ts index ebcc7a2..9eb3c3b 100644 --- a/test/types.ts +++ b/test/types.ts @@ -1,10 +1,13 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { expectTypeOf } from "expect-type"; -import Router from ".."; -import { ErrorRequestHandler } from "../lib/route"; +import Router, { ErrorRequestHandler, Method, NextFunction, Path, RequestHandler } from ".."; const router = Router(); +const handler: RequestHandler = (req, res, next) => next(); +const other: RequestHandler = (req, res, next) => next(); + +// handler parameters are inferred router.get("/", function (req, res) { expectTypeOf(req).toEqualTypeOf(); expectTypeOf(res).toEqualTypeOf(); @@ -23,3 +26,93 @@ router.get("/", function (err, req, res, next) { expectTypeOf(res).toEqualTypeOf(); expectTypeOf(next).toBeFunction(); }); + +// methods accept multiple handlers and nested arrays (flattened at runtime) +router.get("/", handler, other); +router.get("/", [handler, other]); +router.get("/", [handler, [other]], handler); + +// at least one handler is required (runtime throws TypeError) +// @ts-expect-error +router.get("/"); +// handlers must be functions or arrays of them +// @ts-expect-error +router.get("/", "not-a-handler"); + +// paths may be a string, RegExp, or array of either +router.get(/^\/x/, handler); +router.get(["/a", /^\/b/], handler); +// @ts-expect-error +router.get(42, handler); + +// use: with or without path, multiple handlers, arrays +router.use(handler); +router.use(handler, other); +router.use([handler, [other]]); +router.use("/a", handler, [other], handler); +router.use(["/a", "/b"], handler); +router.use(/^\/c/, handler); +// @ts-expect-error path without any handler (runtime throws TypeError) +router.use("/x"); +// @ts-expect-error +router.use(); + +// verb methods and use return the router for chaining +expectTypeOf(router.get("/", handler)).toEqualTypeOf(router); +router.use(handler).use("/a", other).get("/", handler); + +// one method per node:http METHODS entry, not just the classic 8 +router.trace("/", handler); +router.search("/", handler); +router.propfind("/", handler); +router["m-search"]("/", handler); +// @ts-expect-error not an HTTP method +router.foo("/", handler); + +// error handlers are distinguished by the explicit generic +router.use(function (err, req, res, next) { + expectTypeOf(err).toEqualTypeOf(); + next(err); +}); + +// param callbacks receive typed req/res +router.param("id", function (req, res, next, value, name) { + expectTypeOf(req).toEqualTypeOf(); + expectTypeOf(res).toEqualTypeOf(); + expectTypeOf(next).toEqualTypeOf(); + expectTypeOf(name).toBeString(); +}); + +// router is itself a request handler (mountable in a server or another router) +expectTypeOf(router).toExtend(); +router.use(Router()); + +// the instance, options, and helper types are nameable by consumers +const mounted: Router.Router = Router({ strict: true, caseSensitive: true, mergeParams: true }); +mounted.use(handler); +const opts: Router.RouterOptions = { strict: true }; +Router(opts); +new Router(opts); +// @ts-expect-error unknown options are rejected +Router({ nope: true }); +expectTypeOf().toExtend(); +expectTypeOf().toEqualTypeOf>(); + +// routes: all verbs, multiple handlers, chaining +const route = router.route("/things/:id"); +expectTypeOf(route.path).toBeString(); +route.all(handler).get(handler, other).post([handler, [other]]); +route.checkout(handler); +route["m-search"](handler); +// @ts-expect-error at least one handler is required +route.put(); + +// Route must be constructed with new, and accepts any Path +new Router.Route("/x"); +new Router.Route(/^\/x/); +new Router.Route(["/a", /^\/b/]); +// @ts-expect-error calling without new does not construct (strict mode) +Router.Route("/x"); +// @ts-expect-error +new Router.Route(42); +