Skip to main content

Overview

tsoa uses TypeScript decorators to extract data from different parts of an HTTP request. Parameter decorators tell tsoa where to find data and automatically validate and transform it according to TypeScript types.
Decorators are applied to method parameters and use TypeScript’s type system to provide automatic validation and OpenAPI documentation.

Request Body

@Body

Extract the entire request body:
import { Post, Body, Route } from '@tsoa/runtime';

@Route('users')
export class UserController {
  @Post()
  public async createUser(@Body() user: User): Promise<User> {
    return await userService.create(user);
  }
}

// Request:
// POST /users
// Content-Type: application/json
// {"name": "John", "email": "[email protected]"}

@BodyProp

Extract a specific property from the request body:
import { Post, BodyProp, Route } from '@tsoa/runtime';

@Route('users')
export class UserController {
  @Post('update-email')
  public async updateEmail(
    @BodyProp() userId: number,
    @BodyProp() email: string
  ): Promise<void> {
    await userService.updateEmail(userId, email);
  }
}

// Request:
// POST /users/update-email
// {"userId": 123, "email": "[email protected]"}
Named property:
@Post('settings')
public async updateSettings(
  @BodyProp('user_id') userId: number,
  @BodyProp('notification_enabled') notificationsEnabled: boolean
): Promise<void> {
  await userService.updateSettings(userId, notificationsEnabled);
}

// Request body uses snake_case:
// {"user_id": 123, "notification_enabled": true}

URL Parameters

@Path

Extract path parameters from the URL:
import { Get, Path, Delete, Route } from '@tsoa/runtime';

@Route('users')
export class UserController {
  @Get('{userId}')
  public async getUser(@Path() userId: number): Promise<User> {
    return await userService.getById(userId);
  }

  @Delete('{userId}/posts/{postId}')
  public async deletePost(
    @Path() userId: number,
    @Path() postId: number
  ): Promise<void> {
    await postService.delete(userId, postId);
  }
}

// Matches: GET /users/123
// Matches: DELETE /users/123/posts/456
Path parameter names in the route must match the parameter names exactly (case-sensitive).
Named path parameter:
@Get('{user_id}')
public async getUser(@Path('user_id') userId: number): Promise<User> {
  return await userService.getById(userId);
}

// URL: /users/123
// The route uses 'user_id' but the parameter is named 'userId'

Query Parameters

@Query

Extract query string parameters:
import { Get, Query, Route } from '@tsoa/runtime';

@Route('products')
export class ProductController {
  @Get('search')
  public async search(
    @Query() query: string,
    @Query() category?: string,
    @Query() page: number = 1,
    @Query() limit: number = 20
  ): Promise<Product[]> {
    return await productService.search({
      query,
      category,
      page,
      limit
    });
  }
}

// Request: GET /products/search?query=laptop&category=electronics&page=2&limit=10
Named query parameter:
@Get('filter')
public async filter(
  @Query('max_price') maxPrice?: number,
  @Query('min_rating') minRating?: number
): Promise<Product[]> {
  return await productService.filter({ maxPrice, minRating });
}

// Request: GET /products/filter?max_price=1000&min_rating=4

@Queries

Extract all query parameters as a single object:
import { Get, Queries, Route } from '@tsoa/runtime';

interface SearchParams {
  query: string;
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  page?: number;
  limit?: number;
}

@Route('products')
export class ProductController {
  @Get('search')
  public async search(@Queries() params: SearchParams): Promise<Product[]> {
    return await productService.search(params);
  }
}

// Request: GET /products/search?query=laptop&category=electronics&minPrice=500
Wildcard queries (any property):
@Get('filter')
public async filterDynamic(
  @Queries() filters: { [key: string]: any }
): Promise<Product[]> {
  return await productService.filterDynamic(filters);
}

// Accepts any query parameters:
// GET /products/filter?color=red&size=large&custom_field=value
Typed record queries:
@Get('stats')
public async getStats(
  @Queries() metrics: { [metric: string]: number }
): Promise<Stats> {
  return await statsService.calculate(metrics);
}

// All query parameters must be numbers:
// GET /products/stats?views=100&clicks=50&conversions=5

Headers

@Header

Extract HTTP headers:
import { Get, Header, Route } from '@tsoa/runtime';

@Route('api')
export class ApiController {
  @Get('data')
  public async getData(
    @Header('authorization') authToken: string,
    @Header('x-api-version') apiVersion?: string,
    @Header('accept-language') language: string = 'en'
  ): Promise<Data> {
    return await dataService.getData(authToken, apiVersion, language);
  }
}

// Request:
// GET /api/data
// Authorization: Bearer token123
// X-API-Version: 2.0
// Accept-Language: en-US
Header names are case-insensitive. Both Authorization and authorization will match.

File Uploads

@UploadedFile

Handle single file uploads:
import { Post, UploadedFile, Route } from '@tsoa/runtime';
import { File } from '@tsoa/runtime';

@Route('files')
export class FileController {
  @Post('upload')
  public async uploadFile(
    @UploadedFile() file: File
  ): Promise<{ url: string }> {
    const url = await fileService.upload(file);
    return { url };
  }

  @Post('avatar')
  public async uploadAvatar(
    @UploadedFile('avatar') avatar: File,
    @UploadedFile('thumbnail') thumbnail?: File
  ): Promise<User> {
    const avatarUrl = await fileService.upload(avatar);
    const thumbnailUrl = thumbnail 
      ? await fileService.upload(thumbnail)
      : undefined;
    
    return await userService.updateAvatar(avatarUrl, thumbnailUrl);
  }
}
File object properties:
interface File {
  originalname: string;  // Original filename
  encoding: string;      // File encoding
  mimetype: string;      // MIME type
  buffer: Buffer;        // File contents
  size: number;         // File size in bytes
}

@UploadedFiles

Handle multiple file uploads:
import { Post, UploadedFiles, Route } from '@tsoa/runtime';

@Route('files')
export class FileController {
  @Post('batch')
  public async uploadMultiple(
    @UploadedFiles('files') files: File[]
  ): Promise<{ urls: string[] }> {
    const urls = await Promise.all(
      files.map(file => fileService.upload(file))
    );
    return { urls };
  }

  @Post('mixed')
  public async uploadMixed(
    @UploadedFile('document') document: File,
    @UploadedFiles('images') images: File[],
    @UploadedFiles('attachments') attachments: File[]
  ): Promise<UploadResult> {
    const docUrl = await fileService.upload(document);
    const imageUrls = await fileService.uploadMany(images);
    const attachmentUrls = await fileService.uploadMany(attachments);
    
    return { docUrl, imageUrls, attachmentUrls };
  }
}

Form Data

@FormField

Extract form fields from multipart/form-data:
import { Post, FormField, UploadedFile, Route } from '@tsoa/runtime';

@Route('users')
export class UserController {
  @Post('profile')
  public async updateProfile(
    @FormField('username') username: string,
    @FormField('bio') bio: string,
    @UploadedFile('avatar') avatar?: File
  ): Promise<User> {
    return await userService.updateProfile({
      username,
      bio,
      avatar
    });
  }

  @Post('upload-document')
  public async uploadDocument(
    @UploadedFile('file') file: File,
    @FormField('title') title: string,
    @FormField('description') description: string,
    @FormField('tags') tags: string
  ): Promise<Document> {
    return await documentService.create({
      file,
      title,
      description,
      tags: tags.split(',')
    });
  }
}

Request Object

@Request

Access the raw framework request object:
import { Get, Request, Route } from '@tsoa/runtime';
import { Request as ExpressRequest } from 'express';

@Route('api')
export class ApiController {
  @Get('info')
  public async getRequestInfo(
    @Request() request: ExpressRequest
  ): Promise<RequestInfo> {
    return {
      ip: request.ip,
      userAgent: request.get('user-agent'),
      protocol: request.protocol,
      method: request.method,
      path: request.path
    };
  }
}
Using @Request couples your code to a specific framework (Express, Koa, etc.). Use specific decorators like @Header when possible for better portability.

@RequestProp

Extract a specific property from the request object:
import { Get, RequestProp, Route } from '@tsoa/runtime';

interface RequestWithUser {
  user?: {
    id: number;
    email: string;
  };
}

@Route('api')
export class ApiController {
  @Get('me')
  public async getCurrentUser(
    @RequestProp('user') user: RequestWithUser['user']
  ): Promise<User> {
    if (!user) throw new Error('Not authenticated');
    return await userService.getById(user.id);
  }
}

Dependency Injection

@Inject

Mark parameters that should be injected by your IoC container:
import { Get, Inject, Route } from '@tsoa/runtime';

@Route('users')
export class UserController {
  @Get()
  public async getUsers(
    @Inject() logger: Logger,
    @Inject() userService: UserService
  ): Promise<User[]> {
    logger.info('Fetching all users');
    return await userService.getAll();
  }
}
Parameters marked with @Inject() won’t be documented in the OpenAPI spec and must be provided by your IoC container.

Content Type

@Consumes

Specify accepted content types for the request body:
import { Post, Body, Consumes, Route } from '@tsoa/runtime';

@Route('files')
export class FileController {
  @Post('upload')
  @Consumes('multipart/form-data')
  public async uploadFile(
    @UploadedFile() file: File
  ): Promise<UploadResult> {
    return await fileService.upload(file);
  }

  @Post('data')
  @Consumes('application/x-www-form-urlencoded')
  public async submitForm(
    @FormField('name') name: string,
    @FormField('email') email: string
  ): Promise<void> {
    await formService.process({ name, email });
  }

  @Post('json-or-xml')
  @Consumes('application/json', 'application/xml')
  public async handleData(@Body() data: Data): Promise<Result> {
    return await dataService.process(data);
  }
}

Type Conversion

tsoa automatically converts parameter types based on TypeScript annotations:
@Get('{id}')
public async getItem(
  @Path() id: number,
  @Query() page: number,
  @Query() limit: number = 10
): Promise<Item> {
  // id, page, and limit are automatically converted to numbers
  return await itemService.get(id, page, limit);
}

// GET /items/123?page=2&limit=20
// id = 123 (number)
// page = 2 (number)
// limit = 20 (number)

Best Practices

Prefer specific decorators over @Request() for better portability:
// Good: Framework-agnostic
@Get('data')
public async getData(
  @Header('authorization') token: string,
  @Query() page: number
): Promise<Data> {
  return await dataService.get(token, page);
}

// Avoid: Coupled to Express
@Get('data')
public async getData(
  @Request() req: ExpressRequest
): Promise<Data> {
  const token = req.get('authorization');
  const page = parseInt(req.query.page as string);
  return await dataService.get(token, page);
}
Use default values for optional parameters:
@Get('items')
public async getItems(
  @Query() page: number = 1,
  @Query() limit: number = 20,
  @Query() sort: 'asc' | 'desc' = 'asc'
): Promise<Item[]> {
  return await itemService.getAll({ page, limit, sort });
}
Define interfaces for complex query parameters:
interface ProductFilters {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  inStock?: boolean;
  tags?: string[];
}

@Get('products')
public async getProducts(
  @Queries() filters: ProductFilters
): Promise<Product[]> {
  return await productService.filter(filters);
}
Always validate file uploads in your service layer:
@Post('upload')
public async uploadFile(
  @UploadedFile() file: File
): Promise<UploadResult> {
  // Validate in service
  if (file.size > 10 * 1024 * 1024) {
    throw new Error('File too large');
  }
  
  if (!file.mimetype.startsWith('image/')) {
    throw new Error('Only images allowed');
  }
  
  return await fileService.upload(file);
}

Next Steps

Validation

Add validation rules to your parameters

Models

Define TypeScript interfaces for complex types

Responses

Handle different response types and status codes

Authentication

Secure your endpoints

Build docs developers (and LLMs) love