exjs-controllers

Getting Started

Install exjs-controllers, set up TypeScript decorators, write your first controller and bootstrap the Express application.

Requirements

  • Node.js >=22
  • TypeScript 5.2 or newer
  • Peers — express ^5 and zod ^4, installed by your project. TypeScript projects also need @types/express.

TypeScript configuration

Parameter decorators such as @Body(), @Param() and @CurrentUser() only exist in TypeScript's legacy decorator implementation, so enable experimentalDecorators. emitDecoratorMetadata is not required: the framework never reads design:* metadata — every decorator receives what it needs explicitly (@Body(CreateUserInput), @Field(z.string())).

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}

TC39 decorators

Class and method decorators (@Controller, @Get, @Authorized, ...) also run under the standard TC39 implementation and detect the mode automatically. Because the standard has no parameter decorators, any project using @Body, @Param, @CurrentUser and friends still needs experimentalDecorators: true.

Project layout

configureApplication registers controllers from an explicit controllers: [...] list or auto-discovers them from one or more directories. The discovery uses the convention *.controller.ts (or .js/.mjs/...) to locate files.

src/
  modules/
    users/
      users.controller.ts
      users.service.ts
      dtos/
        create-user.input.ts
        user.output.ts
  server.ts

Bootstrap

src/server.ts
import { createApplication } from 'exjs-controllers/http/application'
import { configureApplication } from 'exjs-controllers/config/configureApplication'

import { UsersController } from './modules/users/users.controller'

const app = createApplication()

await configureApplication(app, {
  controllers: [UsersController],
  openapi: {
    documentation: {
      info: { title: 'My API', version: '1.0.0' }
    }
  },
  enableScalar: true
})

app.listen(3000, () => {
  console.log('Listening on http://localhost:3000')
})

createApplication() returns a typed wrapper around Express that exposes the underlying instance as app.express while keeping the surface area focused (use, get, post, put, patch, delete, listen, address, close).

Your first controller

src/modules/users/users.controller.ts
import { z } from 'zod'
import { JsonController } from 'exjs-controllers/decorators/Controller'
import { Get, Post } from 'exjs-controllers/decorators/HttpMethod'
import { Body, Param } from 'exjs-controllers/decorators/Params'
import { Field } from 'exjs-controllers/decorators/Field'
import { BaseSchema } from 'exjs-controllers/schemas/BaseSchema'

class CreateUserInput extends BaseSchema {
  @Field(z.string().min(1))
  name!: string

  @Field(z.string().email())
  email!: string
}

class UserOutput extends BaseSchema {
  @Field(z.string())
  id!: string

  @Field(z.string())
  name!: string

  @Field(z.string())
  email!: string
}

@JsonController('/users')
export class UsersController {
  @Get('/:id', { outputClass: UserOutput, summary: 'Get user' })
  findOne(@Param('id') id: string): UserOutput {
    return UserOutput.create({ id, name: 'Alice', email: 'alice@example.com' })
  }

  @Post('/', { inputClass: CreateUserInput, outputClass: UserOutput })
  create(@Body(CreateUserInput) input: CreateUserInput): UserOutput {
    input.validate()
    return UserOutput.create({ id: '1', name: input.name, email: input.email })
  }
}

Next steps

On this page