@gpa/type-safe-express
v1.0.1
Published
Provide a way to use express routers in a type-safe manner
Downloads
115
Readme
@gpa/type-safe-express
Type-safe-express provides a way to use express in a type-safe manner:
- The path parameters available in each request handler are automatically extracted from the path on which the request handler is mounted
- Runtime validators for
request.body
andrequest.query
can be provided, in which case these values will be typed according to the validated type in the request handler - The response type of each route must be explicitly specified, which prevents accidentally returning an incorrect value in request handlers
Table of Contents
Installation
# Install Typescript
npm install -D typescript @types/node
# Install the library
npm install @gpa/type-safe-express
# Install peer dependencies: latest express 5.x and @types/express
npm install 'express@>=5.0.0-beta'
npm install -D @types/express
# Optional dependencies: install a validation library
npm install zod
npm install yup
⚠️ If you do not explicitly add a dependency on express 5, then express 4 will be installed in your node_modules (unless a stable version of express 5 has been released at the time you are reading this). Read the Requirements section below for information on compatibility with express 4.
Requirements
Node.js >= 18
typescript >= 5.4
This library uses the NoInfer intrinsic type introduced in TypeScript 5.4
express >= 5.0
express 4 is partially supported, but path syntax breaking changes have been introduced in express@5 and the now-deprecated syntaxes are not supported by this package.
zod >= 3.0 (optional)
yup >= 1.4 (optional)
Usage
Basic usage
import { declareRoute } from '@gpa/type-safe-express';
import express from 'express';
const app = express();
// The first type argument of declareRoute determines the expected return type of the route
// By default, the response is returned with a "200 OK" status code
declareRoute<string>()(app, 'get', '/api/:apiVersion/version', {}, (request) => {
// request.params is inferred as { apiVersion: string }
return request.params.apiVersion;
});
app.listen(3000);
Router composition
import { declareRoute, createRouter } from '@gpa/type-safe-express';
import express from 'express';
import { UserDto, UserSettingDto } from './entities/user';
import { UserService } from './services/user.service.js';
const app = express();
const apiRouter = createRouter(app, '/api/:apiVersion');
// Do not add a type-annotation to the request parameter to let the library infers its precise type
declareRoute<string>()(apiRouter, 'get', '/version', {}, (request) => {
// request.params is inferred as { apiVersion: string }
return request.params.apiVersion;
});
const userRouter = createRouter(apiRouter, '/users');
// If the router parameter has been created with createRouter, all the path parameters declared on parent routers will be available
// in request.params
declareRoute<UserDto>()(userRouter, 'get', '/:userId', {}, async (request) => {
// request.params is inferred as { apiVersion: string, userId: string }
const user = await UserService.get(request.params.userId);
return UserService.mapUserToDto(user);
});
const userSettingsRouter = createRouter(userRouter, '/:userId/settings');
declareRoute<UserSettingDto>()(
userSettingsRouter,
'get',
// Even "complex" path parameters are properly parsed by the library
'/:userSettingType-:userSettingPath([\w$/-]+)',
{},
// You can use a named and/or async function as the request handler
async function getUserSettingRequestHandler(request) {
// request.params is inferred as { apiVersion: string, userId: string, userSettingType: string, userSettingPath: string }
const { userId, userSettingType, userSettingPath } = request.params;
const userSetting = await UserService.getUserSetting(userId, { type: userSettingType, path: userSettingPath });
return UserService.mapUserSettingToDto(userSetting);
},
);
app.listen(3000);
Request body and query validation with Zod
import { declareRoute, TypedRouterValidationError, HttpStatusCode } from '@gpa/type-safe-express';
import express from 'express';
import { z } from 'zod';
import { EntityService } from './services/entity.service.js';
const bodySchema = z.object({ id: z.number(), name: z.string() });
const querySchema = z.object({ model_version: z.number().optional() });
const app = express();
app.use(express.json());
declareRoute<{ ok: boolean }>()(app, 'put', '/entity/:entityId', { body: bodySchema, query: querySchema }, async (request) => {
// => request.body has been validated with the bodySchema and is now inferred as { id: number, name: string }
// => request.query has been validated with the querySchema and is now inferred as { model_version?: number }
await EntityService.updateEntity(request.params.entityId, request.body, { modelVersion: request.query.model_version });
return { ok: true };
});
// Basic express error handler to handle validation errors
app.use((err, req, res, next) => {
// The type of the error can be further narrowed with `err instanceof TypedRouterZodValidationError`
// or `err instanceof TypedRouterYupValidationError`
if (err instanceof TypedRouterValidationError) {
res.status(HttpStatusCode.BadRequest_400).send({ ok: false, message: err.message });
} else {
res.status(HttpStatusCode.InternalServerError_500).send({ ok: false, message: String(err) });
}
});
app.listen(3000);
Response with custom status and headers
import { declareRoute, HttpStatusCode, VoidResponse } from '@gpa/type-safe-express';
import express from 'express';
import { SERVER_VERSION } from './constants.js';
const app = express();
declareRoute<VoidResponse>()(app, 'get', '/api', {}, (request) => {
return {
json: undefined,
status: HttpStatusCode.NoContent_204,
headers: {
'Date': new Date().toUTCString(),
'X-Server-Version': SERVER_VERSION,
},
cookies: {
mycookie: {
value: 'myvalue',
options: { expires: new Date(Date.now() + 60000), secure: true },
},
},
};
});
app.listen(3000);
Response streaming
import { declareRoute, TypedRouterStreamingError, StreamResponse } from '@gpa/type-safe-express';
import { Readable } from 'node:stream';
import express from 'express';
const app = express();
app.use(express.json());
declareRoute<StreamResponse>()(app, 'get', '/entity/:entityId/resource/:resourcePath+', {}, async (request) => {
const resourceReader: Readable = await service.getEntityResourceReader(request.params.entityId, request.params.resourcePath);
return {
stream: resourceReader,
headers: {
'keep-alive': 'timeout=2',
},
};
});
// Basic express error handler to handle streaming errors
app.use((err, req, res, next) => {
if (err instanceof TypedRouterStreamingError) {
// Note that when TypedRouterStreamingError are thrown:
// - the response head has already been sent, so you cannot send a status code or any new headers
// - `.destroy()` has been called on both the `express.Response` stream and the readable stream returned by the request handler
console.error(`Error while streaming response: ${err.cause}`);
} else {
res.status(500).send({ ok: false, message: String(err) });
}
});
API
createRouter(parentRouter, pathSpec, routerOptions?)
Creates a new express.Router
mounted on parentRouter
on the given path. Routers returned by this method will keep track of the path on
which they have been mounted, which allows declareRoute()
to properly analyze the path parameters declared on parent routers.
parentRouter
Type: PathAwareRouter
| express.Router
The router on which the current router will be mounted.
See the documentation of the value returned by this method below for more information on the PathAwareRouter
type.
pathSpec
Type: string
The path on which the created router will be mounted relative on the parentRouter
.
routerOptions
Type: express.RouterOptions
| undefined
The options used when instantiating the express.Router
.
See the express documentation for more information.
⚠️ By default, the express.Router
will be created with { mergeParams: true }
. Setting this option to false
will prevent routers from
accessing the path parameters declared on their parent routers at runtime.
Returned value
Type: PathAwareRouter
The created router.
💡 PathAwareRouter
is a branded type of express.Router
which keeps track of the path specification of its parent router or routers.
At runtime, there are no differences between a PathAwareRouter
and an express.Router
, this is only visible to the Typescript compiler.
declareRoute<Response = undefined, ParentPath = ''>()(router, method, pathSpec, validators, requestHandler)
Declares a new route on the given router. The requestHandler
function is mostly type-safe: the type of request.params
, request.body
and request.query
have been narrowed according to the path specification and the types validated by the validators
parameter, and the
type of the value returned in the HTTP response is controlled by the <Response>
type argument.
<Response>
Type: JsonResponse
| StreamResponse
This type argument determines the expected return value of the request handler.
- For JSON endpoints, any JSON-compatible value can be used (so no functions or classes).
void
is not supported, but you can useundefined
or theVoidResponse
alias for a route that does not return any data. - Using
StreamResponse
or any type implementingNodeJS.ReadableStream
will make the request handler streams its response with aTransfer-Encoding: chunk
header.
Usage:
import { declareRoute, VoidResponse, StreamResponse } from '@gpa/type-safe-express';
import { Readable } from 'node:stream';
const router = express.Router();
declareRoute<VoidResponse>()(router, 'get', '/', {}, (request) => {
// return 'string'; => Error: Type string is not assignable to type RequestHandlerReturnValue<undefined>
// return { json: 'string' }; => Error: Type string is not assignable to type undefined
// return; => Valid response
// return undefined; => Valid response
// return { json: undefined }; => Valid response
// no return statement => Valid response
});
declareRoute<{ property: number }>()(router, 'get', '/path', {}, (request) => {
// no return statement => Error: Type void is not assignable to type RequestHandlerReturnValue<NoInfer<{ property: number; }>>
// return { property: 'a string' }; => Error: Type { property: string; } is not assignable to type { property: number; }
return { property: 0 }; // => Valid response
});
declareRoute<StreamResponse>()(router, 'get', '/stream', {}, (request) => {
return Readable.from([]);
});
<ParentPath>
Type: string
This type argument allows the request.params
in the request handler to also include path parameters declared in parent routers.
If the router passed as the first argument has been created using createRouter()
, you
do not need to explicitly give a value to this type argument as it will be automatically inferred from the TypeAwareRouter
.
💡 The express.Router
passed as the first function parameter must be declared using the { mergeParams: true }
options to also expose
parent router parameters under request.params
. Routers created with createRouter()
use this option by default.
Usage:
const app = express();
const router = express.Router({ mergeParams: true });
const parentPath = '/parentPath/:parentParam';
app.use(parentPath, router);
declareRoute<void, typeof parentPath>()(
router,
'get',
'/:childParam',
{},
(request) => {
// => type of `request.params` is { parentParam: string, childParam: string }
},
);
router
Type: PathAwareRouter
| express.Router
The express router on which the current route must be declared. Accepts express.Application
s as well since they extend routers.
method
Type: ExpressHttpMethod
The allowed values for this parameter are automatically extracted from the express library into the ExpressHttpMethod
type.
Non-exhaustive list of supported values: get
, post
, put
, patch
, delete
, options
, all
, etc.
pathSpec
Type: string
A standard express path specification which will ultimately be parsed by the path-to-regexp module.
Path parameters will be automatically extracted from this string to provide a precise type for request.params
in the request handler, this
includes named and unnamed parameters. Parameter quantifiers (?*+
) are taken into account so that optional parameters are typed as being
potentially undefined
in request.params
.
⚠️ Express@5 introduces multiple breaking changes on the allowed path syntax, this library implements these changes and is therefore not entirely compatible with Express@4 paths.
Usage:
declareRoute()(router, 'get', '/path/prefix-:param1.:param2(\\w{2,3})+/path/(\\d+)+/path/:param3*', {}, (request) => {
// => type of `request.params` is inferred as { param1: string, param2: string, param3?: string | undefined, 0: string }
});
validators
Type: { body?: TypePredicate, query?: TypePredicate }
TypePredicate
can be a function returning an explicit
or implicit
(with Typescript >= 5.5) type-predicate, a zod.Schema
or a yup.Schema
.
Usage:
// With a zod.Schema
const zodSchema = z.object({ property: z.string() });
declareRoute()(router, 'post', '/', { body: zodSchema }, (request) => {
// => type of `request.body` is inferred as { property: string }
});
// With a yup.Schema
const yupSchema = yup.object({ property: yup.string().required() });
declareRoute()(router, 'post', '/', { body: yupSchema }, (request) => {
// => type of `request.body` is inferred as { property: string }
});
// With an explicit type predicate
const typePredicate = (value: unknown): value is { property: string } =>
typeof value === 'object'
&& value !== null
&& 'property' in value
&& typeof value.property === 'string';
declareRoute()(router, 'post', '/', { body: typePredicate }, (request) => {
// => type of `request.body` is inferred as { property: string }
});
// With an implicit type predicate (Typescript >= 5.5)
declareRoute()(router, 'post', '/', { body: v => typeof v === 'number' || typeof v === 'string' }, (request) => {
// => type of `request.body` is inferred as string | number
});
💡 A TypedRouterValidationError
(or TypedRouterZodValidationError
if using Zod, or TypedRouterYupValidationError
if using Yup) will be
thrown if a validation fails, which you probably want to handle in an appropriate error handler:
declareRoute()(/* ... */);
app.use((err: unknown, req, res, next) => {
if (err instanceof TypedRouterValidationError) {
res.status(400).send('Bad Request');
} else {
// ...
}
});
validators.body
Type: TypePredicate
| undefined
This validator is used to infer the type of request.body
in the request handler and to actually validate the body received in the HTTP
request at runtime.
If no value is provided for validators.body
, request.body
will have the type unknown
in the request handler.
💡 When using a Zod or Yup validator, the output of zodSchema.parse(rawRequestBody)
or yupSchema.cast(rawRequestBody)
will overwrite the
request.body
value, so any property not declared in the schema will be removed, and any value parsed/coerced/transformed by the schema
will also be transformed in request.body
.
Conversely, when using a type predicate, the value of the request.body
will not be changed whatsoever, so any property not declared in
the RequestBody type will still be present in request.body
, unless the type predicate explicitly checks for extraneous properties.
💡 You need a body-parser when initializing your express Application to
receive parsed JSON values in the body of the incoming HTTP requests.
validators.query
Type: TypePredicate
| undefined
This validator is used to infer the type of request.query
in the request handler and to actually validate the parsed query-string received
in the HTTP request at runtime.
If no value is provided for validators.query
, request.query
will have the type unknown
in the request handler.
💡 Contrary to validators.body
, request.query
cannot be overwritten so the runtime value will always include any property that is not
declared in the TypePredicate
.
💡 By default, express 5 uses the simple
query parser setting, which defers the query-string parsing to the
node:querystring
module. If you are using express 4, the default query parser setting is
extended
as described below.
By using the extended
query parser (app.set('query parser', 'extended')
), the parsing is deferred to the
qs
package, which allows for more complex structures to be parsed from the query-string.
Please refer to the documentation of these modules for more information on the supported query-string syntax.
requestHandler
Type: (request: express.Request) => RequestHandlerReturnValue
This function is where you can process an incoming HTTP request on the declared route.
The request
parameter is a standard express.Request
object that has been typed according to the other arguments: request.body
has the
type validated by the validators.body
parameter, and request.query
the type validated by the validators.query
parameter.
The returned value should either be a value with the type of <Response>
(the first type argument of declareRoute
), or a
ComplexResponse
object which has the following structure:
type TypedRouteComplexResponse<Response> = {
// HttpStatusCode is an exported enum containing all the standard HTTP status codes. This property also accepts numbers as long as they
// correspond to an existing status code.
status?: HttpStatusCode;
headers?: {
[header: string]: number | string | string[]
};
cookies?: {
// CookieOptions is documented in the express documentation: https://expressjs.com/en/api.html#res.cookie
[cookieName: string]: string | { value: string; options?: CookieOptions };
};
// For JSON responses:
json: Response;
// For streaming responses (with Response extends ResponseType):
stream: Response;
};
💡 The returned value can be a Promise or a synchronous value, so async
handlers are perfectly fine.
💡 If you need to use the express.Response
object in your handler, you can access it under request.res
.
Returned value
Type: (router, method, pathSpec, validators, requestHandler) => void
declareRoute()
returns an intermediate function calledrouterFactory
, which should be used immediately as described throughout this documentation. The parameters ofrouterFactory
are the ones have been described in this documentation.routerFactory()
does not return any value, so neither doesdeclareRoute()()
.
💡 The reason behind the existence of this intermediate function routerFactory()
is that the type arguments of declareRoute()
are to
be manually provided, while the type arguments of routerFactory()
are inferred from its function arguments. In Typescript, a function
cannot easily have optional non-inferred type-arguments and inferred type arguments, so this is the best way to solve this issue.