January 9, 2026
Kazi Ehsan Aziz
It is possible for your Express.js Controller function to look like this.
import { Request as ExpRequest } from 'express';
import { Body, Controller, Post, Path, Request, Response, Route, Security, SuccessResponse, Tags, Middlewares } from '@tsoa/runtime';
import * as postService from '@/modules/post/post.service.js';
import auth from '@/modules/auth/auth.middleware.js';
import validate from '@/lib/validate/validate.middleware.js';
// ...
// ...
@Route('v1/posts')
@Tags('Posts')
export class PostController extends Controller {
/**
* Invite Authors
*/
@Response<IApiResponse<any>>(400, 'Validation failed.')
@Response<IApiResponse<any>>(401, 'Unauthorized')
@Response<IApiResponse<any>>(404, 'Not found')
@SuccessResponse('200', 'OK')
@Middlewares(validate(postValidation.inviteAuthors))
@Security('jwt') // just adds auth specs to openapi
@Middlewares(auth()) // actually handles auth
@Post('{postId}/invite-authors')
public async inviteAuthors(
@Path() postId: string,
@Body() requestBody: InviteAuthorsDto,
@Request() request: ExpRequest
): Promise<IApiResponse<PostDto>> {
const userId = request.user._id.toString();
const result = await postService.inviteAuthors(postId, requestBody, userId);
if (isServiceFailureResponse(result)) {
throw new ApiError(result.code, result.message);
}
return {
success: true,
code: httpStatus.OK,
message: 'Post updated successfully',
data: result,
};
}
}
tsoa allows us to use NestJS-like decorators in our Express.js application. It reads decorated TypeScript controllers and generates two things from them: an Express routes file and an OpenAPI spec. You stop writing the spec by hand, and the controllers get small. The route, its middlewares, its documented responses, and its request and response types all sit on one method.
tsoa's documentation covers setup and the use of each decorator well. It says less about how the pieces behave together in a real backend:
So, this article is not a walkthrough on how to setup tsoa, but more about some explanations and better practices you may want to implement after setting up tsoa. The example application here is built with Express and TypeScript running as native ES modules, deployed to AWS Lambda.
Two middlewares have already appeared in the code snippet above.
auth(), which authenticates a JWT using Passport, andvalidate(), which runs Joi schemas. Both are out of scope here. For this article, they are just your custom Express middlewares.
Executing tsoa spec-and-routes reads your decorated Controller classes and writes
src/specs/swagger.json and src/routes/routes.ts. Let's look at the tsoa.json
configuration quickly.
{
"entryFile": "src/index.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"controllerPathGlobs": ["src/**/*.controller.ts"],
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"spec": {
"outputDirectory": "src/specs",
"specVersion": 3,
"securityDefinitions": {
"jwt": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"routes": {
"routesDir": "src/routes",
"esm": true,
"authenticationModule": "src/modules/auth/auth.tsoa.ts",
"iocModule": "src/lib/utils/ioc.ts"
}
}
compilerOptions: tsoa builds its own TypeScript program to read your
controllers and DTO types, and it builds it from this block alone. It
never reads tsconfig.json. If your code imports through path aliases
like @/lib/dto/posts.js, the aliases have to be repeated here, or tsoa
can't resolve the types behind those imports.routes.esm: with "type": "module" and NodeNext resolution,
relative imports need a .js extension. This flag makes the generated
routes.ts import your post.controller.js rather than an extensionless path.noImplicitAdditionalProperties: what tsoa's runtime validation does
with body properties your DTO type doesn't declare. The default is
ignore. throw-on-extras rejects the request with a ValidateError.spec.securityDefinitions: the key, jwt, is the name that
@Security('jwt') refers to.authenticationModule and iocModule get their own sections
below.It is worth opening the generated routes file once, because most of the quirks in this article are visible in it. Trimmed and reformatted to show only one API endpoint:
app.post(
'/v1/posts/:postId/invite-authors',
authenticateMiddleware([{ jwt: [] }]),
...fetchMiddlewares<RequestHandler>(PostController),
...fetchMiddlewares<RequestHandler>(PostController.prototype.inviteAuthors),
async function PostController_inviteAuthors(request, response, next) {
try {
const validatedArgs = templateService.getValidatedArgs({
args: argsPostController_inviteAuthors,
request,
response,
});
const controller = await iocContainer.get<PostController>(PostController);
controller.setStatus(undefined);
await templateService.apiHandler({
methodName: 'inviteAuthors',
controller,
response,
next,
validatedArgs,
successStatus: 200,
});
} catch (err) {
return next(err);
}
}
);
Reading it top to bottom:
authenticateMiddleware comes from @Security. It always runs first. @Security
runs the authentication and permissions logic you write in the module that
authenticationModule points to in your tsoa.json.@Middlewares, then the method-level ones.getValidatedArgs is tsoa's own runtime validation of path, query and
body, against your TypeScript types.new PostController().successStatus is baked in from @SuccessResponse.next(err), so your Express error middleware
handles it.The intuitive reading of @Security('jwt') is "this endpoint requires a
JWT". In the spec, that is exactly what it means: Swagger UI shows a lock on
the API endpoint.
At runtime it means something more. tsoa inserts
authenticateMiddleware([{ jwt: [] }]) into the route, and that middleware
calls a function named expressAuthentication, imported by name from your
authenticationModule. tsoa expects you to verify the token there, and
whatever the function resolves becomes request.user.
You could do all of your auth in that function, and pass required
permissions through the decorator's scopes array. Then the auth logic
would live in a tsoa-specific function with a tsoa-specific signature. I wanted
auth to be an ordinary Express middleware factory that takes the required
permissions as plain arguments, like auth(PERMISSIONS.moderateForumPosts)
or auth(). So my expressAuthentication does nothing:
import * as express from 'express';
export function expressAuthentication(
_request: express.Request,
_securityName: string,
_scopes?: string[]
): Promise<any> {
/*
* This just tells TSOA to add auth specs to openapi.
* Actual authentication is handled by a custom middleware.
*/
return Promise.resolve();
}
The module still has to exist and export the exact name expressAuthentication,
since I use @Security for the purpose of notifying spec about secured endpoints.
My actual authentication enforcement happens through @Middlewares(auth()). And
I mentioned in the beginning of the article that auth() is just a custom Express
middleware that uses Passport to authenticate JWTs.
That is why the pair (@Middlewares(auth()) & @Security('jwt')) sits together
on every protected method, each with a comment saying which half does what.
authenticateMiddleware writes the resolved value to request['user'].
Ours resolves undefined, so request.user is undefined until
@Middlewares(auth()) runs and sets the real user. This is harmless because
authenticateMiddleware always runs before every @Middlewares entry. It
does mean any middleware that reads request.user has to run after
auth(), which brings us to order.
Middleware order is bottom-to-top. TypeScript applies stacked decorators from
the bottom up. The decorator nearest to the method applies before the one above it.
Except for @Security('jwt'), which we already saw from the generated routes file
that it runs first.
The same "does this actually do anything?" question applies to every decorator on the method:
@Route, @Post, @Path, @Body. They define the
Express route, and how arguments are extracted and validated.@Middlewares, and @Request, which hands the method
the raw Express request.expressAuthentication: @Security.@SuccessResponse('201') becomes
successStatus: 201 in the generated route, and the response uses it
unless the method calls this.setStatus().@Response, @Tags, and the JSDoc comment above the
method, which becomes the operation's description.The tsoa package is a thin wrapper around two others:
// node_modules/tsoa/package.json
"dependencies": {
"@tsoa/cli": "^6.6.0",
"@tsoa/runtime": "^6.6.0"
}
@tsoa/cli is the code generator. It depends on the TypeScript compiler
(its own nested copy), handlebars, yargs, glob and more, and takes about
25 MB on disk. @tsoa/runtime is under 500 KB.
A running app never needs the generator. @tsoa/runtime exports every
decorator, and the generated routes.ts already imports from it:
import type { TsoaRoute } from "@tsoa/runtime";
import { fetchMiddlewares, ExpressTemplateService } from "@tsoa/runtime";
So the CLI moves to devDependencies, the runtime becomes the production
dependency, and every import in your src switches over:
"devDependencies": {
+ "tsoa": "6.6.0",
},
"dependencies": {
+ "@tsoa/runtime": "6.6.0",
- "tsoa": "^6.4.0",
}
- import { Body, Controller, Post, Route } from 'tsoa';
+ import { Body, Controller, Post, Route } from '@tsoa/runtime';
A few details:
^. The CLI that
generates routes.ts and the runtime that executes it should never drift
apart.ioc.ts might also
be importing something from tsoa depending on how you set up dependency injection.
Mine did not, but I was importing ValidateError in my error converter middleware. All
these imports need to happen from @tsoa/runtime.In my deployment case, the Lambda package zip size went from 26 MB down to 11 MB. That is less to upload on every deploy, and less for Lambda to fetch and unpack whenever it spins up a new instance.
The move creates a new failure mode. A file that still imports tsoa keeps
working on your machine, because devDependencies are installed there. It
only fails in the deployed Lambda, with ERR_MODULE_NOT_FOUND.
The no-extraneous-dependencies rule from eslint-plugin-import turns that
into a lint error. It flags any import of a devDependency outside an
explicit allowlist:
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"**/*.test.*",
"**/*.spec.*",
"**/lambda.ts",
"**/setupTestDB.ts",
"**/swagger.route.ts"
]
}
]
Serving the spec gets the same treatment. swagger-ui-express is a
devDependency, and the docs route is mounted only in development, through a
dynamic import:
import express from 'express';
import config from '@/config/config.js';
const router = express.Router();
if (config.env === 'development') {
// A static import is hoisted and evaluated unconditionally, so production
// would still load swagger-ui-express for a route it never mounts.
const { default: docsRoute } = await import('@/routes/v1/swagger.route.js');
router.use('/docs', docsRoute);
}
export default router;
In development, a change to a .ts file in src goes through three steps
before the server sees it: regenerate routes.ts and the spec, compile
TypeScript, restart Node. So the pipeline is split into three watchers, started
together by yarn dev:
"compile": "tsoa spec-and-routes && tsc --build && tsc-alias",
"postcompile": "cp package*json dist/ && cp -r src/specs dist/",
"tsoa:watch": "onchange 'src/**/*.ts' -e 'src/routes/routes.ts' -e 'src/specs/*.json' -- tsoa spec-and-routes",
"compile:watch": "onchange 'src/routes/routes.ts' -- sh -c 'tsc --build && tsc-alias'",
"pre:dev": "cross-env NODE_ENV=development nodemon --enable-source-maps --watch dist dist/index.js",
"dev": "yarn compile && concurrently --kill-others \"yarn tsoa:watch\" \"yarn compile:watch\" \"yarn pre:dev\"",
What makes it work:
routes.ts is the relay. compile:watch doesn't watch your source.
It watches the generated file, so tsc only starts once tsoa has finished
writing. A tsc --watch running in parallel with the generator would race
it, and compile against a stale routes.ts.routes.ts exclusion is required. routes.ts is itself a .ts
file under src/. Without the -e exclusion, tsoa's own output would
trigger tsoa again, forever.routes.ts on every run, even when its content is identical, and
onchange fires on the write. tsoa has a routes.noWriteIfUnchanged
option that sounds like a harmless optimisation. With this pipeline,
turning it on means an edit to a service, or to the body of a controller
method, never reaches tsc.tsc-alias runs after every build. tsc emits the @/ path aliases
unchanged, and tsc-alias rewrites them into relative paths Node can
resolve.yarn compile runs once before the watchers, so dist/ exists when
nodemon boots. --kill-others stops the whole group if one process dies.src/routes/v1/swagger.route.ts reads src/specs/swagger.json relative
to the compiled code, which is why postcompile copies src/specs into dist/.
Yarn 1 and npm run post* scripts automatically after compile.
Yarn 2+ does not, so there you would chain the copy into compile itself.Without an iocModule, the generated route constructs the controller with
new PostController(). There are no constructor arguments, so there is no
way to hand the controller its dependencies. With iocModule set in your
tsoa.json, the route asks your container instead:
import { iocContainer } from "./../lib/utils/ioc.js";
const container: IocContainer =
typeof iocContainer === "function"
? (iocContainer as IocContainerFactory)(request)
: iocContainer;
const controller: any = await container.get<PostController>(PostController);
So your module has to export a binding named exactly iocContainer. Let's
look at it:
import { Container } from 'inversify';
// Create a new container tsoa can use
const iocContainer = new Container();
// export according to convention
export { iocContainer };
If PostService were a class, the service, the controller and the bindings
would look like this:
import { injectable } from "inversify";
@injectable()
export class PostService {
async inviteAuthors(
postId: string,
body: InviteAuthorsDto,
userId: string,
): Promise<PostDto | IServiceFailureResponse> {
// ...
}
}
import { inject, injectable } from "inversify";
import { PostService } from "@/modules/post/post.service.js";
@Route("v1/posts")
@Tags("Posts")
@injectable()
export class PostController extends Controller {
constructor(@inject(PostService) private readonly postService: PostService) {
super();
}
@Post("{postId}/invite-authors")
public async inviteAuthors(
@Path() postId: string,
@Body() requestBody: InviteAuthorsDto,
@Request() request: ExpRequest,
): Promise<IApiResponse<PostDto>> {
const userId = request.user._id.toString();
const result = await this.postService.inviteAuthors(
postId,
requestBody,
userId,
);
// ...
}
}
import { RegisterRoutes } from "@/routes/routes.js";
// ...
const app: Express = express();
// ...
app.use(passport.initialize() as any);
passport.use("jwt", jwtStrategy);
// ...
// Register services
iocContainer.bind(PostService).toSelf().inSingletonScope();
// ...
// Register controllers
iocContainer.bind(PostController).toSelf();
// ...
// v1 api routes
app.use("/v1", routes);
RegisterRoutes(app);
// send back a 404 error for any unknown api request
app.use((_req, _res, next) => {
next(new ApiError(httpStatus.NOT_FOUND, "Not found"));
});
// convert error to ApiError, if needed
app.use(errorConverter);
// handle error
app.use(errorHandler);
Three pieces make that work, and each fails in its own way when it is missing:
@injectable() on every class the container constructs, the
controller included. It marks a class as one Inversify may build. The
Inversify 6.2 release this app runs is more lenient than that. A class
with an empty constructor resolves without the @injectable(). So does one whose
constructor parameter types were recorded some other way: e.g. by @inject().
The failure case is a class with constructor dependencies and no decorators:
Inversify still constructs it, and the dependency is silently undefined.
Rather than lean on that leniency, put @injectable() on every class you bind,
and @inject() on every constructor parameter.@injectable() doesn't register anything. Without
bind(PostService), resolving the controller throws
No matching bindings found for serviceIdentifier: PostService. That
happens on the first request to that controller, not at startup, because
the generated route only asks the container at request time.inSingletonScope()) is fine, and every new controller
instance receives the same PostService.Mind the order in the app.ts too. RegisterRoutes(app) goes after your body
parsers and Passport initialisation, and before the 404 handler and the
error middleware. The generated routes pass every error to next(err), so
the error middleware has to be registered after them to receive it.
toSelf() without a scope uses Inversify's default, transient: a new
controller instance for every request. It's tempting to add
.inSingletonScope() to the controller bindings too,
like the service. Before you do, look at tsoa's Controller:
// @tsoa/runtime/dist/interfaces/controller.js
class Controller {
constructor() {
this.statusCode = undefined;
this.headers = {};
}
setStatus(statusCode) {
this.statusCode = statusCode;
}
setHeader(name, value) {
this.headers[name] = value;
}
}
A response's status code and headers live on the controller instance. The
generated route resets the status with controller.setStatus(undefined) at
the start of each request in routes.ts, and reads it back once the method
resolves. With one shared instance on a long-running server, concurrent
requests share that state across every await. One request's
this.setStatus(201) can be reset by a request that starts in between, or
end up in someone else's response. Nothing resets headers at all, so on
a singleton a header set once is sent with every later response.
Without an iocModule, tsoa creates a controller per request. Transient
bindings keep that behaviour.
@Security and @Middlewares(auth()) together if you are using
a custom Express middleware to authenticate requests.@Middlewares run bottom-to-top, after whatever @Security adds.devDependencies, import everything from
@tsoa/runtime, pin both versions, and lint for stray tsoa imports.routes.ts, and exclude it
from tsoa's own watcher.iocContainer, bind every controller and service
explicitly, and keep controllers transient.