Response Types

Send JSON, HTML, streams, and custom responses with typed status codes

JustScale's HTTP transport provides a flexible response API with type safety for different content types and status codes.

JSON Responses

res.json(data)

The most common response method sends JSON with a 200 OK status:

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';

export const UsersController = createController('/users', {
  routes: () => ({
    list: Get('/').handle(({ res }) => {
      res.json({
        users: [
          { id: '1', name: 'Alice' },
          { id: '2', name: 'Bob' },
        ],
      });
      // HTTP 200 OK
      // Content-Type: application/json
    }),
  }),
});

Custom Status Codes

Use res.status(code).json(data) to send JSON with a specific status code:

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';

function createUser(body: any) {
  return { id: '1', name: body.name };
}

export const UsersController = createController('/users', {
  routes: () => ({
    // In a controller's routes
    create: Post('/').handle(({ body, res }) => {
      const user = createUser(body);
      res.status(201).json({ user });
      // HTTP 201 Created
    }),
  }),
});

Common status codes:

  • 200 - OK (default for res.json)
  • 201 - Created (after successful POST)
  • 202 - Accepted (async processing)
  • 204 - No Content (successful DELETE)

Empty Responses

res.status(code).end()

Send an empty response with just a status code (useful for 204, 403, 409, etc.):

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Delete } from '@justscale/http';

function deleteUser(id: string) {
  console.log('Deleting user', id);
}

export const UsersController = createController('/users', {
  routes: () => ({
    // In a controller's routes
    remove: Delete('/:id').handle(({ params, res }) => {
      deleteUser(params.id);
      res.status(204).end();
      // HTTP 204 No Content (no body)
    }),
  }),
});
src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';

function checkUserExists(email: string) {
  return email === 'exists@example.com';
}

export const UsersController = createController('/users', {
  routes: () => ({
    // In a controller's routes
    create: Post('/').handle(({ body, res }) => {
      const exists = checkUserExists(body.email);
      if (exists) {
        res.status(409).end();
        // HTTP 409 Conflict (no body needed)
        return;
      }
      // Create user...
    }),
  }),
});

Error Responses

res.error(message, status?)

Send error responses with a message and optional status code (defaults to 400):

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { UserService } from '../services/user-service';

export const UsersController = createController('/users', {
  inject: { users: UserService },
  routes: (services) => ({
    getOne: Get('/:id').handle(({ params, res }) => {
      const user = services.users.findById(params.id);
      if (!user) {
        res.error('User not found', 404);
        // HTTP 404: { "error": "User not found" }
        return;
      }
      res.json({ user });
    }),
  }),
});

Common error status codes:

  • 400 - Bad Request (validation error)
  • 401 - Unauthorized (missing auth)
  • 403 - Forbidden (insufficient permissions)
  • 404 - Not Found (resource doesn't exist)
  • 409 - Conflict (duplicate resource)
  • 422 - Unprocessable Entity (semantic error)
  • 500 - Internal Server Error

Typed Response Schemas

Use the .returns() method to declare response types. Once declared,res.status().json() and res.status().end() are type-checked against your declared schemas:

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Get, Post, Delete } from '@justscale/http';
import { populate, body } from '@justscale/http/builder';
import { UserService } from '../services/user-service';
import { UserResponse, ErrorResponse, CreateUserBody } from '../schemas/user';

export const UsersController = createController('/users', {
  inject: { users: UserService },

  routes: (services) => ({
    // res.json() is typed to only accept UserResponse
    getOne: Get('/:userId')
      .use(populate(services.users, 'user', 'userId'))
      .returns(UserResponse)
      .returns(404)
      .handle(({ user, res }) => {
        res.json({ user });  // TypeScript: { user: { id: string, name: string, email: string } }
        // res.json({ wrong: 'shape' }); // TypeScript ERROR!
      }),

    // res.status(201).json() typed to UserResponse
    // res.status(400).json() typed to ErrorResponse
    create: Post('/')
      .apply(body(CreateUserBody))
      .returns(UserResponse, 201)
      .returns(ErrorResponse, 400)
      .handle(async ({ body, res }) => {
        const existing = await services.users.findByEmail(body.email);
        if (existing) {
          // TypeScript knows this must be ErrorResponse shape
          return res.status(400).json({ error: 'Email taken' });
        }
        const user = await services.users.create(body);
        // TypeScript knows this must be UserResponse shape
        res.status(201).json({ user });
      }),

    // res.status(204).end() - no body required
    remove: Delete('/:userId')
      .use(populate(services.users, 'user', 'userId'))
      .returns(204)
      .returns(404)
      .handle(async ({ user, res }) => {
        await services.users.delete(user.id);
        res.status(204).end();  // No body needed for 204
      }),
  }),
});

Type Safety Benefits

  • res.json(data) — TypeScript validates data matches the 200 response schema
  • res.status(201).json(data) — TypeScript validates data matches the 201 response schema
  • res.status(400).json(data) — TypeScript validates data matches the 400 error schema
  • res.status(204).end() — Only allowed when 204 is declared (no body)
  • Undeclared status codes cause TypeScript errors at compile time
ℹ️

Info

The .returns() method provides compile-time type checking and enables automatic OpenAPI schema generation.

Multiple Response Types

For routes that can return different status codes, you can chain multiple .returns() calls:

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { UserResponse, ErrorResponse } from '../schemas/user';

function findUser(id: string) {
  return id === '1' ? { id, name: 'Alice' } : null;
}

export const UsersController = createController('/users', {
  routes: () => ({
    getOne: Get('/:id')
      .returns(UserResponse)              // 200 response
      .returns(ErrorResponse, 404)        // 404 response
      .handle(({ params, res }) => {
        const user = findUser(params.id);
        if (!user) {
          res.status(404).json({ error: 'User not found' });
          return;
        }
        res.json({ user });
      }),
  }),
});

HTML Responses

While JustScale is primarily designed for JSON APIs, you can send HTML by manually setting headers:

src/controllers/home.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';

// Note: JustScale is designed for JSON APIs
// For HTML, you'd extend the res object or use custom middleware
export const HomeController = createController('/', {
  routes: () => ({
    home: Get('/').handle(({ res }) => {
      res.json({ message: 'Use res.json() for JSON APIs' });
    }),
  }),
});
⚠️

Warning

JustScale is optimized for JSON APIs. For HTML rendering, consider using a dedicated frontend framework or template engine alongside your API.

Streaming Responses

The current HTTP server implementation doesn't directly expose streaming APIs, but you can work around it by accessing the raw response object if needed.

ℹ️

Info

For streaming use cases (Server-Sent Events, file downloads, etc.), you may need to extend the HTTP server or use a custom transport. JustScale's transport-agnostic design makes this possible.

Response Headers

The HTTP server automatically sets common headers:

  • Content-Type: application/json for JSON responses
  • Access-Control-Allow-Origin: * for CORS

To set custom headers, you'll need to extend the response object or use middleware.

Auto-204 Behavior

If your handler doesn't send any response, JustScale automatically sends a 204 No Content:

src/controllers/webhooks.tsTypeScript
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';

function processWebhook(body: any) {
  console.log('Processing webhook:', body);
}

export const WebhooksController = createController('/webhooks', {
  routes: () => ({
    github: Post('/github').handle(({ body }) => {
      // Process webhook
      processWebhook(body);
      // No response sent - JustScale sends 204 No Content
    }),
  }),
});
ℹ️

Info

This is useful for webhooks and fire-and-forget endpoints where you don't need to send a response body.

Response Patterns

RESTful CRUD

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Get, Post, Put, Delete } from '@justscale/http';
import { UserService } from '../services/user-service';

export const UsersController = createController('/users', {
  inject: { users: UserService },
  routes: (services) => ({
    // List: 200 with array
    list: Get('/').handle(({ res }) => {
      res.json({ users: services.users.findAll() });
    }),

    // Get One: 200 or 404
    getOne: Get('/:id').handle(({ params, res }) => {
      const user = services.users.findById(params.id);
      if (!user) {
        res.error('User not found', 404);
        return;
      }
      res.json({ user });
    }),

    // Create: 201 with created resource
    create: Post('/').handle(({ body, res }) => {
      const user = services.users.create(body);
      res.status(201).json({ user });
    }),

    // Update: 200 with updated resource
    update: Put('/:id').handle(({ params, body, res }) => {
      const user = services.users.update(params.id, body);
      res.json({ user });
    }),

    // Delete: 204 (no content)
    remove: Delete('/:id').handle(({ params, res }) => {
      services.users.delete(params.id);
      res.status(204).end();
    }),
  }),
});

Conditional Responses

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';

const users = {
  findByEmail: (email: string) => email === 'exists@example.com' ? { id: '1' } : null,
  create: (body: any) => ({ id: '2', ...body }),
};

export const UsersController = createController('/users', {
  routes: () => ({
    // In a controller's routes
    create: Post('/').handle(({ body, res }) => {
      const exists = users.findByEmail(body.email);
      if (exists) {
        res.status(409).json({
          error: 'User already exists',
          existingId: exists.id,
        });
        return;
      }

      const user = users.create(body);
      res.status(201).json({ user });
    }),
  }),
});

Partial Success

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';

const users = {
  create: (userData: any) => ({ id: '1', ...userData }),
};

export const UsersController = createController('/users', {
  routes: () => ({
    // In a controller's routes
    bulkCreate: Post('/bulk').handle(({ body, res }) => {
      const results = body.users.map((userData: any) => {
        try {
          return { success: true, user: users.create(userData) };
        } catch (err) {
          return { success: false, error: (err as Error).message };
        }
      });

      const hasFailures = results.some(r => !r.success);
      res.status(hasFailures ? 207 : 201).json({ results });
      // 207 Multi-Status for partial success
    }),
  }),
});