Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ app.listen(3000, () => {

app.get('/:foo', req => {
req.params.foo; // $ExpectType string
req.params[0]; // $ExpectType string
req.params.bar; // $ExpectError
req.params[0]; // $ExpectError
// $ExpectType string | false | null
req.is(['application/json', 'application/xml']);
// $ExpectType string | false | null
Expand All @@ -19,7 +20,8 @@ app.get('/:foo', req => {

app.route('/:foo').get(req => {
req.params.foo; // $ExpectType string
req.params[0]; // $ExpectType string
req.params.bar; // $ExpectError
req.params[0]; // $ExpectError
// $ExpectType string | false | null
req.is(['application/json', 'application/xml']);
// $ExpectType string | false | null
Expand All @@ -41,7 +43,7 @@ app.route('/*').get<express.ParamsArray>(req => {
});

// Params can be a custom type
// NB. out-of-the-box all params are strings, however, other types are allowed to accomadate request validation/coersion middleware
// NB. out-of-the-box all params are strings, however, other types are allowed to accommodate request validation/coercion middleware
app.get<{ foo: string; bar: number }>('/:foo/:bar', req => {
req.params.foo; // $ExpectType string
req.params.bar; // $ExpectType number
Expand Down Expand Up @@ -89,14 +91,30 @@ app.get('/nextrouter', (req, res, next) => {

// Default types
app.post('/', (req, res) => {
req.params[0]; // $ExpectType string
req.params[0]; // $ExpectError

req.body; // $ExpectType any
res.send('ok'); // $ExpectType Response<any, Record<string, any>, number>
});

// Default types - under route
app.route('/').post((req, res) => {
req.params[0]; // $ExpectError

req.body; // $ExpectType any
res.send('ok'); // $ExpectType Response<any, Record<string, any>, number>
});

// Default types - not using RouteParameters
app.post('/' as string, (req, res) => {
Copy link

@robertvanhoesel robertvanhoesel May 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a decent fallback workaround for non standard implementations. Would users still be able to provide custom parameters like in https://github.com/DefinitelyTyped/DefinitelyTyped/pull/51262/files#diff-c5d8f61506d81e402ccb7f5f031730ce5ee044b86777af13924d7edfae4243f1R45

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, this works:

app.get<{ foo: string; bar: number }>('/' as string, req => {
    req.params.foo; // $ExpectType string
    req.params.bar; // $ExpectType number
    req.params.baz; // $ExpectError
});

i can add a test for this if you'd like

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great, good to have this workaround on hand for when it might break someones implementation (although it is hard to imagine how it would). This is a great PR!

req.params[0]; // $ExpectType string

req.body; // $ExpectType any
res.send('ok'); // $ExpectType Response<any, Record<string, any>, number>
});

// Default types - not using RouteParameters - under route
app.route('/' as string).post((req, res) => {
req.params[0]; // $ExpectType string

req.body; // $ExpectType any
Expand Down
116 changes: 87 additions & 29 deletions types/express-serve-static-core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,47 @@ export type RequestHandlerParams<
| ErrorRequestHandler<P, ResBody, ReqBody, ReqQuery, Locals>
| Array<RequestHandler<P> | ErrorRequestHandler<P>>;

export type RouteParameterNames<Route extends string> =
string extends Route
? string
: Route extends `${string}:${infer Param}/${infer Rest}`
? (Param | RouteParameterNames<Rest>)
: (Route extends `${string}:${infer LastParam}` ? LastParam : never);

export type RouteParameters<T extends string> = {
[key in RouteParameterNames<T>]: string
};

export interface IRouterMatcher<
T,
Method extends 'all' | 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head' = any
> {
<
Route extends string,
P = RouteParameters<Route>,
ResBody = any,
ReqBody = any,
ReqQuery = ParsedQs,
Locals extends Record<string, any> = Record<string, any>
>(
// tslint:disable-next-line no-unnecessary-generics (it's used as the default type parameter for P)
path: Route,
// tslint:disable-next-line no-unnecessary-generics (This generic is meant to be passed explicitly.)
...handlers: Array<RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals>>
): T;
<
Path extends string,
P = RouteParameters<Path>,
ResBody = any,
ReqBody = any,
ReqQuery = ParsedQs,
Locals extends Record<string, any> = Record<string, any>
>(
// tslint:disable-next-line no-unnecessary-generics (it's used as the default type parameter for P)
path: Path,
// tslint:disable-next-line no-unnecessary-generics (This generic is meant to be passed explicitly.)
...handlers: Array<RequestHandlerParams<P, ResBody, ReqBody, ReqQuery, Locals>>
): T;
<
P = ParamsDictionary,
ResBody = any,
Expand All @@ -120,9 +157,29 @@ export interface IRouterMatcher<
(path: PathParams, subApplication: Application): T;
}

export interface IRouterHandler<T> {
(...handlers: RequestHandler[]): T;
(...handlers: RequestHandlerParams[]): T;
export interface IRouterHandler<T, Route extends string = string> {
(...handlers: Array<RequestHandler<RouteParameters<Route>>>): T;
(...handlers: Array<RequestHandlerParams<RouteParameters<Route>>>): T;
<
P = RouteParameters<Route>,
ResBody = any,
ReqBody = any,
ReqQuery = ParsedQs,
Locals extends Record<string, any> = Record<string, any>
>(
// tslint:disable-next-line no-unnecessary-generics (This generic is meant to be passed explicitly.)
...handlers: Array<RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals>>
): T;
<
P = RouteParameters<Route>,
ResBody = any,
ReqBody = any,
ReqQuery = ParsedQs,
Locals extends Record<string, any> = Record<string, any>
>(
// tslint:disable-next-line no-unnecessary-generics (This generic is meant to be passed explicitly.)
...handlers: Array<RequestHandlerParams<P, ResBody, ReqBody, ReqQuery, Locals>>
): T;
<
P = ParamsDictionary,
ResBody = any,
Expand Down Expand Up @@ -216,41 +273,42 @@ export interface IRouter extends RequestHandler {

use: IRouterHandler<this> & IRouterMatcher<this>;

route<T extends string>(prefix: T): IRoute<T>;
route(prefix: PathParams): IRoute;
/**
* Stack of configured routes
*/
stack: any[];
}

export interface IRoute {
export interface IRoute<Route extends string = string> {
path: string;
stack: any;
all: IRouterHandler<this>;
get: IRouterHandler<this>;
post: IRouterHandler<this>;
put: IRouterHandler<this>;
delete: IRouterHandler<this>;
patch: IRouterHandler<this>;
options: IRouterHandler<this>;
head: IRouterHandler<this>;

checkout: IRouterHandler<this>;
copy: IRouterHandler<this>;
lock: IRouterHandler<this>;
merge: IRouterHandler<this>;
mkactivity: IRouterHandler<this>;
mkcol: IRouterHandler<this>;
move: IRouterHandler<this>;
'm-search': IRouterHandler<this>;
notify: IRouterHandler<this>;
purge: IRouterHandler<this>;
report: IRouterHandler<this>;
search: IRouterHandler<this>;
subscribe: IRouterHandler<this>;
trace: IRouterHandler<this>;
unlock: IRouterHandler<this>;
unsubscribe: IRouterHandler<this>;
all: IRouterHandler<this, Route>;
get: IRouterHandler<this, Route>;
post: IRouterHandler<this, Route>;
put: IRouterHandler<this, Route>;
delete: IRouterHandler<this, Route>;
patch: IRouterHandler<this, Route>;
options: IRouterHandler<this, Route>;
head: IRouterHandler<this, Route>;

checkout: IRouterHandler<this, Route>;
copy: IRouterHandler<this, Route>;
lock: IRouterHandler<this, Route>;
merge: IRouterHandler<this, Route>;
mkactivity: IRouterHandler<this, Route>;
mkcol: IRouterHandler<this, Route>;
move: IRouterHandler<this, Route>;
'm-search': IRouterHandler<this, Route>;
notify: IRouterHandler<this, Route>;
purge: IRouterHandler<this, Route>;
report: IRouterHandler<this, Route>;
search: IRouterHandler<this, Route>;
subscribe: IRouterHandler<this, Route>;
trace: IRouterHandler<this, Route>;
unlock: IRouterHandler<this, Route>;
unsubscribe: IRouterHandler<this, Route>;
}

export interface Router extends IRouter {}
Expand Down
7 changes: 7 additions & 0 deletions types/express-serve-static-core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"private": true,
"types": "index",
"typesVersions": {
"<=4.0": { "*": ["ts4.0/*"] }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import * as express from 'express-serve-static-core';

const app: express.Application = {} as any;
app.listen(3000);
app.listen(3000, () => {
// no-op error callback
});

app.get('/:foo', req => {
req.params.foo; // $ExpectType string
req.params[0]; // $ExpectType string
// $ExpectType string | false | null
req.is(['application/json', 'application/xml']);
// $ExpectType string | false | null
req.is('audio/wav');
// $ExpectError
req.is(1);
});

app.route('/:foo').get(req => {
req.params.foo; // $ExpectType string
req.params[0]; // $ExpectType string
// $ExpectType string | false | null
req.is(['application/json', 'application/xml']);
// $ExpectType string | false | null
req.is('audio/wav');
// $ExpectError
req.is(1);
});

// Params can used as an array
app.get<express.ParamsArray>('/*', req => {
req.params[0]; // $ExpectType string
req.params.length; // $ExpectType number
});

// Params can used as an array - under route
app.route('/*').get<express.ParamsArray>(req => {
req.params[0]; // $ExpectType string
req.params.length; // $ExpectType number
});

// Params can be a custom type
// NB. out-of-the-box all params are strings, however, other types are allowed to accommodate request validation/coercion middleware
app.get<{ foo: string; bar: number }>('/:foo/:bar', req => {
req.params.foo; // $ExpectType string
req.params.bar; // $ExpectType number
req.params.baz; // $ExpectError
});

// Params can be a custom type - under route
app.route('/:foo/:bar').get<{ foo: string; bar: number }>(req => {
req.params.foo; // $ExpectType string
req.params.bar; // $ExpectType number
req.params.baz; // $ExpectError
});

// Optional params currently not supported
// TODO: support optional params - https://github.com/DefinitelyTyped/DefinitelyTyped/pull/51262#discussion_r638786417
app.get('/:foo/:bar?', req => {
req.params.foo; // $ExpectType string
req.params['bar?']; // $ExpectType string
});

// Query can be a custom type
app.get<{}, any, any, { q: string }>('/:foo', req => {
req.query.q; // $ExpectType string
req.query.a; // $ExpectError
});

// Query can be a custom type - under route
app.route('/:foo').get<{}, any, any, { q: string }>(req => {
req.query.q; // $ExpectType string
req.query.a; // $ExpectError
});

// Query will be defaulted to Query type
app.get('/:foo', req => {
req.query; // $ExpectType ParsedQs
});

// Query will be defaulted to Query type - under route
app.route('/:foo').get(req => {
req.query; // $ExpectType ParsedQs
});

// Next can receive a Error parameter to delegate to Error handler
app.get('/nexterr', (req, res, next) => {
next(new Error('dummy')); // $ExpectType void
});

// Next can receive a 'router' parameter to fall back to next router
app.get('/nextrouter', (req, res, next) => {
next('router'); // $ExpectType void
});

// Default types
app.post('/', (req, res) => {
req.params[0]; // $ExpectType string

req.body; // $ExpectType any
res.send('ok'); // $ExpectType Response<any, Record<string, any>, number>
});

// Default types - under route
app.route('/').post((req, res) => {
req.params[0]; // $ExpectType string

req.body; // $ExpectType any
res.send('ok'); // $ExpectType Response<any, Record<string, any>, number>
});

// No params, only response body type
app.get<never, { foo: string }>('/', (req, res) => {
req.params.baz; // $ExpectError

res.send({ foo: 'ok' }); // $ExpectType Response<{ foo: string; }, Record<string, any>, number>
req.body; // $ExpectType any
});

// No params, only response body type - under route
app.route('/').get<never, { foo: string }>((req, res) => {
req.params.baz; // $ExpectError

res.send({ foo: 'ok' }); // $ExpectType Response<{ foo: string; }, Record<string, any>, number>
req.body; // $ExpectType any
});

// No params, request body type and response body type
app.post<never, { foo: string }, { bar: number }>('/', (req, res) => {
req.params.baz; // $ExpectError

res.send({ foo: 'ok' }); // $ExpectType Response<{ foo: string; }, Record<string, any>, number>
req.body.bar; // $ExpectType number

res.json({ baz: 'fail' }); // $ExpectError
req.body.baz; // $ExpectError
});

// No params, request body type and response body type - under route
app.route('/').post<never, { foo: string }, { bar: number }>((req, res) => {
req.params.baz; // $ExpectError

res.send({ foo: 'ok' }); // $ExpectType Response<{ foo: string; }, Record<string, any>, number>
req.body.bar; // $ExpectType number

res.json({ baz: 'fail' }); // $ExpectError
req.body.baz; // $ExpectError
});

app.engine('ntl', (_filePath, _options, callback) => {
callback(new Error('not found.'));
});

// Status test
{
type E = express.Response<unknown, any, 'abc'>; // $ExpectError
type B = express.Response<unknown, any, 123>;
type C = Parameters<B['status']>[0]; // $ExpectType 123
type D = Parameters<B['sendStatus']>[0]; // $ExpectType 123
}

// Locals can be a custom type
app.get<{}, any, any, {}, { foo: boolean }>('/locals', (req, res, next) => {
res.locals.foo; // $ExpectType boolean
res.locals.bar; // $ExpectError
res.send({ foo: 'ok' }); // $ExpectType Response<any, { foo: boolean; }, number>
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Express docs have a few examples of tricky routes: https://expressjs.com/en/guide/routing.html#route-parameters

It would be a good idea to add them to the tests here to make sure they work as expected:

  • /flights/:from-:to
  • /plantae/:genus.:species
  • /user/:userId(\d+)

While it's not explicitly mentioned on that page, if you click through to path-to-regexp, it mentions that parameters can be optional, i.e. /path/:param? should make the type of req.params.param be string | undefined.

This may be tested elsewhere, but the path can also be a regular expression, and that should continue to work as before (there are no regex literals… yet!).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, wasn't aware of this. i'll add support for -, . and ?, though the regex will probably just have to be ignored

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about just adding a test for /path/:param? so that we know what the behavior is after this PR? Even if we know it's incorrect, it will be a good place to capture the context from this discussion and what more correct behavior would be in the future.

Copy link
Contributor Author

@DetachHead DetachHead May 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done. though i'm keen to rewrite the RouteParameters type to support this as well as properly ignore regex wrapped in parentheses. i think it'll need to be more like a lexer instead of just splitting on '/'|'-'|'.'. i'll get on that tomorrow

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading