Skip to main content

Overview

tsoa provides decorators and utilities for handling various response scenarios, including success responses, error responses, custom status codes, and headers. All responses are automatically documented in your OpenAPI specification.
Response types and status codes defined with decorators are included in the generated API documentation.

Basic Responses

The simplest response is returning a Promise from your controller method:
import { Route, Get, Post, Body } from '@tsoa/runtime';

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

  // Returns 200 OK with array
  @Get()
  public async getUsers(): Promise<User[]> {
    return await userService.getAll();
  }

  // Returns 200 OK with void (empty response)
  @Delete('{userId}')
  public async deleteUser(userId: number): Promise<void> {
    await userService.delete(userId);
  }
}

Success Response

Document successful responses with @SuccessResponse:
import { Route, Post, Body, SuccessResponse, Controller } from '@tsoa/runtime';

@Route('users')
export class UserController extends Controller {
  @Post()
  @SuccessResponse('201', 'Created') // Status code and description
  public async createUser(@Body() user: User): Promise<User> {
    const created = await userService.create(user);
    
    // Set the status code
    this.setStatus(201);
    
    // Set location header
    this.setHeader('Location', `/users/${created.id}`);
    
    return created;
  }
}

Custom Success Status Codes

import { Controller, Route, Post, Put, Delete, SuccessResponse } from '@tsoa/runtime';

@Route('posts')
export class PostController extends Controller {
  @Post()
  @SuccessResponse('201', 'Post created')
  public async create(@Body() post: Post): Promise<Post> {
    const created = await postService.create(post);
    this.setStatus(201);
    return created;
  }

  @Put('{postId}')
  @SuccessResponse('200', 'Post updated')
  public async update(
    postId: number,
    @Body() post: Post
  ): Promise<Post> {
    return await postService.update(postId, post);
  }

  @Delete('{postId}')
  @SuccessResponse('204', 'Post deleted')
  public async delete(postId: number): Promise<void> {
    await postService.delete(postId);
    this.setStatus(204);
  }
}

Error Responses

Document error responses with @Response:
import { Route, Get, Response } from '@tsoa/runtime';

export interface ErrorResponse {
  message: string;
  code: string;
}

export interface ValidationError {
  message: string;
  fields: Record<string, string>;
}

@Route('users')
export class UserController {
  @Get('{userId}')
  @Response<ErrorResponse>('404', 'User not found')
  @Response<ErrorResponse>('500', 'Internal server error')
  public async getUser(userId: number): Promise<User> {
    const user = await userService.getById(userId);
    
    if (!user) {
      throw new Error('User not found');
    }
    
    return user;
  }

  @Post()
  @Response<ValidationError>('400', 'Validation error')
  @Response<ErrorResponse>('500', 'Internal server error')
  public async createUser(@Body() user: User): Promise<User> {
    return await userService.create(user);
  }
}

Multiple Error Responses

import { Route, Get, Post, Response } from '@tsoa/runtime';

export interface NotFoundError {
  message: string;
  resourceType: string;
  resourceId: number;
}

export interface UnauthorizedError {
  message: string;
  reason: string;
}

export interface ForbiddenError {
  message: string;
  requiredPermissions: string[];
}

@Route('posts')
export class PostController {
  @Get('{postId}')
  @Response<NotFoundError>('404', 'Post not found')
  @Response<UnauthorizedError>('401', 'Authentication required')
  @Response<ForbiddenError>('403', 'Insufficient permissions')
  @Response<ErrorResponse>('500', 'Internal server error')
  public async getPost(postId: number): Promise<Post> {
    return await postService.getById(postId);
  }
}

TsoaResponse Type

Use TsoaResponse type for type-safe manual response handling:
import { Route, Get, Res, TsoaResponse } from '@tsoa/runtime';

export interface ErrorModel {
  message: string;
  code: string;
}

@Route('users')
export class UserController {
  /**
   * @param notFoundResponse Response for when user is not found
   */
  @Get('{userId}')
  public async getUser(
    userId: number,
    @Res() notFoundResponse: TsoaResponse<404, ErrorModel>
  ): Promise<User> {
    const user = await userService.getById(userId);
    
    if (!user) {
      // Type-safe error response
      return notFoundResponse(404, {
        message: 'User not found',
        code: 'USER_NOT_FOUND'
      });
    }
    
    return user;
  }
}

TsoaResponse with Headers

import { Route, Get, Post, Res, TsoaResponse } from '@tsoa/runtime';

// Define response type with status, body, and headers
export type BadRequest = TsoaResponse<
  400,
  { message: string },
  { 'x-error-code': string }
>;

export type RateLimitError = TsoaResponse<
  429,
  { message: string; retryAfter: number },
  { 'retry-after': string; 'x-rate-limit-remaining': string }
>;

@Route('api')
export class ApiController {
  /**
   * @param badRequest Bad request response
   * @param rateLimitError Rate limit response
   */
  @Get('data')
  public async getData(
    @Res() badRequest: BadRequest,
    @Res() rateLimitError: RateLimitError
  ): Promise<Data> {
    // Check rate limit
    const rateLimit = await rateLimitService.check();
    
    if (rateLimit.exceeded) {
      return rateLimitError(429, {
        message: 'Rate limit exceeded',
        retryAfter: rateLimit.retryAfter
      }, {
        'retry-after': rateLimit.retryAfter.toString(),
        'x-rate-limit-remaining': '0'
      });
    }
    
    return await dataService.getData();
  }
}

Multiple TsoaResponse Parameters

import { Route, Get, Res, TsoaResponse } from '@tsoa/runtime';

export type NotFound = TsoaResponse<404, { message: string }>;
export type Unauthorized = TsoaResponse<401, { message: string }>;
export type Forbidden = TsoaResponse<403, { message: string; required: string[] }>;

@Route('posts')
export class PostController {
  /**
   * @param notFound Not found response
   * @param unauthorized Unauthorized response
   * @param forbidden Forbidden response
   */
  @Get('{postId}')
  public async getPost(
    postId: number,
    @Res() notFound: NotFound,
    @Res() unauthorized: Unauthorized,
    @Res() forbidden: Forbidden
  ): Promise<Post> {
    const user = await authService.getCurrentUser();
    
    if (!user) {
      return unauthorized(401, { message: 'Authentication required' });
    }
    
    const post = await postService.getById(postId);
    
    if (!post) {
      return notFound(404, { message: 'Post not found' });
    }
    
    const canView = await authService.canView(user, post);
    
    if (!canView) {
      return forbidden(403, {
        message: 'Insufficient permissions',
        required: ['read:posts']
      });
    }
    
    return post;
  }
}

Union Status Codes

import { Route, Get, Query, Res, TsoaResponse } from '@tsoa/runtime';

export type ClientError = TsoaResponse<
  400 | 404 | 422,
  { message: string; code: string }
>;

export type ServerError = TsoaResponse<
  500 | 503,
  { message: string }
>;

@Route('data')
export class DataController {
  /**
   * @param clientError Client error response
   * @param serverError Server error response
   */
  @Get()
  public async getData(
    @Query() id: number,
    @Res() clientError: ClientError,
    @Res() serverError: ServerError
  ): Promise<Data> {
    if (!id) {
      return clientError(400, {
        message: 'ID is required',
        code: 'MISSING_ID'
      });
    }
    
    const data = await dataService.getById(id);
    
    if (!data) {
      return clientError(404, {
        message: 'Data not found',
        code: 'NOT_FOUND'
      });
    }
    
    return data;
  }
}

Custom Headers

Set custom response headers:
import { Controller, Route, Get, Post } from '@tsoa/runtime';

@Route('files')
export class FileController extends Controller {
  @Get('{fileId}')
  public async downloadFile(fileId: string): Promise<Buffer> {
    const file = await fileService.getById(fileId);
    
    // Set custom headers
    this.setHeader('Content-Type', file.mimeType);
    this.setHeader('Content-Disposition', `attachment; filename="${file.name}"`);
    this.setHeader('Content-Length', file.size.toString());
    this.setHeader('Cache-Control', 'public, max-age=3600');
    
    return file.data;
  }

  @Post('upload')
  public async uploadFile(@Body() file: FileUpload): Promise<FileMetadata> {
    const uploaded = await fileService.upload(file);
    
    // Set location header
    this.setHeader('Location', `/files/${uploaded.id}`);
    
    // Set custom header
    this.setHeader('X-File-Id', uploaded.id);
    
    this.setStatus(201);
    
    return uploaded;
  }
}

Content Types

Produces Decorator

Specify response content types:
import { Route, Get, Post, Produces } from '@tsoa/runtime';

@Route('data')
export class DataController {
  // JSON response (default)
  @Get('json')
  @Produces('application/json')
  public async getJson(): Promise<Data> {
    return await dataService.getData();
  }

  // XML response
  @Get('xml')
  @Produces('application/xml')
  public async getXml(): Promise<string> {
    const data = await dataService.getData();
    return xmlSerializer.serialize(data);
  }

  // Plain text response
  @Get('text')
  @Produces('text/plain')
  public async getText(): Promise<string> {
    return 'Hello, World!';
  }

  // Binary response
  @Get('download')
  @Produces('application/octet-stream')
  public async download(): Promise<Buffer> {
    return await fileService.getFile();
  }
}

Multiple Content Types

import { Route, Get, Produces, Header } from '@tsoa/runtime';

@Route('data')
export class DataController {
  @Get()
  @Produces('application/json', 'application/xml')
  public async getData(
    @Header('accept') accept?: string
  ): Promise<string | Data> {
    const data = await dataService.getData();
    
    if (accept === 'application/xml') {
      return xmlSerializer.serialize(data);
    }
    
    return data;
  }
}

Controller-Level Produces

import { Route, Get, Produces } from '@tsoa/runtime';

@Route('api')
@Produces('application/json')  // Applies to all methods
export class ApiController {
  @Get('data')
  public async getData(): Promise<Data> {
    return await dataService.getData();
  }

  @Get('users')
  public async getUsers(): Promise<User[]> {
    return await userService.getAll();
  }

  @Get('special')
  @Produces('application/xml')  // Override for specific method
  public async getSpecial(): Promise<string> {
    const data = await dataService.getSpecial();
    return xmlSerializer.serialize(data);
  }
}

Streaming Responses

Return streaming responses:
import { Route, Get } from '@tsoa/runtime';
import { Readable } from 'stream';

@Route('streams')
export class StreamController {
  @Get('data')
  public async streamData(): Promise<Readable> {
    const readable = new Readable();
    
    // Simulate streaming data
    let count = 0;
    const interval = setInterval(() => {
      if (count < 10) {
        readable.push(`Data chunk ${count}\n`);
        count++;
      } else {
        readable.push(null); // End stream
        clearInterval(interval);
      }
    }, 1000);
    
    return readable;
  }

  @Get('file')
  public async streamFile(): Promise<Readable> {
    return fileService.createReadStream('large-file.dat');
  }
}

Response Examples

Provide response examples with the @Response decorator:
import { Route, Get, Response } from '@tsoa/runtime';

export interface Product {
  id: number;
  name: string;
  price: number;
}

export interface ErrorResponse {
  message: string;
  code: string;
}

@Route('products')
export class ProductController {
  @Get('{productId}')
  @Response<ErrorResponse>('404', 'Product not found', {
    message: 'Product with ID 123 not found',
    code: 'PRODUCT_NOT_FOUND'
  })
  @Response<ErrorResponse>('500', 'Server error', {
    message: 'An unexpected error occurred',
    code: 'INTERNAL_ERROR'
  })
  public async getProduct(productId: number): Promise<Product> {
    return await productService.getById(productId);
  }
}

Common Response Patterns

import { Controller, Route, Get, Post, Put, Delete, Body, SuccessResponse, Response } from '@tsoa/runtime';

@Route('items')
export class ItemController extends Controller {
  // List: 200 OK
  @Get()
  public async list(): Promise<Item[]> {
    return await itemService.getAll();
  }

  // Read: 200 OK or 404 Not Found
  @Get('{id}')
  @Response<ErrorResponse>('404', 'Item not found')
  public async get(id: number): Promise<Item> {
    const item = await itemService.getById(id);
    if (!item) throw new Error('Not found');
    return item;
  }

  // Create: 201 Created
  @Post()
  @SuccessResponse('201', 'Created')
  public async create(@Body() item: CreateItemRequest): Promise<Item> {
    const created = await itemService.create(item);
    this.setStatus(201);
    this.setHeader('Location', `/items/${created.id}`);
    return created;
  }

  // Update: 200 OK
  @Put('{id}')
  @Response<ErrorResponse>('404', 'Item not found')
  public async update(
    id: number,
    @Body() item: UpdateItemRequest
  ): Promise<Item> {
    return await itemService.update(id, item);
  }

  // Delete: 204 No Content
  @Delete('{id}')
  @SuccessResponse('204', 'Deleted')
  @Response<ErrorResponse>('404', 'Item not found')
  public async delete(id: number): Promise<void> {
    await itemService.delete(id);
    this.setStatus(204);
  }
}

Best Practices

Follow HTTP semantics for status codes:
// 200 OK - Successful GET, PUT, PATCH
// 201 Created - Successful POST that creates a resource
// 204 No Content - Successful DELETE
// 400 Bad Request - Invalid input
// 401 Unauthorized - Missing or invalid authentication
// 403 Forbidden - Authenticated but not authorized
// 404 Not Found - Resource doesn't exist
// 409 Conflict - Conflicting state (e.g., duplicate)
// 422 Unprocessable Entity - Valid syntax but semantic errors
// 500 Internal Server Error - Unexpected server errors

@Route('users')
export class UserController extends Controller {
  @Post()
  @SuccessResponse('201', 'Created')
  @Response<ValidationError>('400', 'Invalid input')
  @Response<ConflictError>('409', 'Email already exists')
  public async create(@Body() user: User): Promise<User> {
    // Implementation
  }
}
Use @Response for all possible responses:
@Route('orders')
export class OrderController {
  @Get('{orderId}')
  @Response<ErrorResponse>('401', 'Unauthorized')
  @Response<ErrorResponse>('403', 'Forbidden')
  @Response<ErrorResponse>('404', 'Order not found')
  @Response<ErrorResponse>('500', 'Internal server error')
  public async getOrder(orderId: number): Promise<Order> {
    return await orderService.getById(orderId);
  }
}
Define error response types:
// Define error types
export interface ApiError {
  message: string;
  code: string;
  timestamp: string;
}

export interface ValidationError extends ApiError {
  fields: Record<string, string[]>;
}

// Use in controllers
@Route('api')
export class ApiController {
  @Post('data')
  @Response<ValidationError>('400', 'Validation failed')
  @Response<ApiError>('500', 'Server error')
  public async createData(@Body() data: Data): Promise<Data> {
    return await dataService.create(data);
  }
}
Include relevant headers in responses:
@Route('resources')
export class ResourceController extends Controller {
  @Post()
  public async create(@Body() resource: Resource): Promise<Resource> {
    const created = await resourceService.create(resource);
    
    // Set location header for created resource
    this.setHeader('Location', `/resources/${created.id}`);
    
    // Set cache control
    this.setHeader('Cache-Control', 'no-cache');
    
    // Set custom headers
    this.setHeader('X-Resource-Version', created.version.toString());
    
    this.setStatus(201);
    return created;
  }
}

Next Steps

Validation

Validate requests before processing

Authentication

Handle authentication errors

Controllers

Learn more about controllers

Models

Define response models

Build docs developers (and LLMs) love