Decorators API
Package: @onebun/core
Module Decorators
@Module()
Defines a module that groups controllers, services, and imports. See Architecture — Module System for concepts and lifecycle details.
@Module(options: ModuleOptions)ModuleOptions:
interface ModuleOptions {
/** Other modules to import (their exported services become available) */
imports?: Function[];
/** Controller classes to register */
controllers?: Function[];
/** Service classes to register as providers */
providers?: unknown[];
/** Services to export to parent modules */
exports?: unknown[];
}Example:
import { Module } from '@onebun/core';
import { CacheModule } from '@onebun/cache';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
imports: [CacheModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}exports accepts services only. Listing a MODULE there — the NestJS re-export idiom — throws OneBunInvalidExportError naming both modules. A re-exported module contributes nothing to the importer — a module's services reach only the modules that import it directly (or every module, for a @Global() one) — so the error names the mistake at boot instead of leaving the importer to fail at a controller with no mention of the export. Import the module that provides the service directly wherever the service is needed:
// Does not work — throws OneBunInvalidExportError at boot
@Module({ imports: [CoreModule], exports: [CoreModule] })
export class FeatureModule {}
// Import the providing module where the service is needed
@Module({ imports: [CoreModule], providers: [UserService] })
export class FeatureModule {}imports must not form a cycle. A module that imports itself — directly, or through the modules it imports — fails start() with OneBunModuleImportCycleError (a OneBunBootstrapError). Each imported module is built before its importer, so a cycle leaves no module to build first. The message lists the cycle in import order and, when the cycle does not start at the root module, the import path the application took to reach it. A cycle always fails this way, never as a RangeError: Maximum call stack size exceeded.
// Throws at start(): "Module import cycle: UsersModule -> UsersModule. ...
// Remove UsersModule from its own imports."
@Module({ imports: [UsersModule] })
export class UsersModule {}A longer cycle — Module import cycle: OrdersModule -> BillingModule -> OrdersModule — is fixed the same way whatever its length: move what the modules on the cycle share into a module that imports none of them, and import that one from each. Split across files, such a cycle usually fails earlier, while the files load, with ReferenceError: Cannot access 'OrdersModule' before initialization.
@Global()
Marks a module as global. Global modules export their providers to all modules automatically without explicit import. This is useful for modules that provide cross-cutting concerns like database access or caching.
@Global()Example:
import { Module, Global } from '@onebun/core';
import { DatabaseService } from './database.service';
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
// Now DatabaseService is available in ALL modules without importing DatabaseModule
@Module({
controllers: [UserController],
providers: [UserService], // UserService can inject DatabaseService
})
export class UserModule {}Related Functions:
// Check if a module is global
function isGlobalModule(target: Function): boolean;
// Remove module from global registry (used internally by forRoot({ isGlobal: false }))
function removeFromGlobalModules(target: Function): void;The registry is process-wide. It holds one entry per module CLASS with no application dimension, so removeFromGlobalModules() changes globality for every application in the process, not just yours. It is not the multi-database mechanism — forRoot({ as: TOKEN }) with forFeature(TOKEN) is.
Two unnamed forRoot() calls that disagree are refused. When a process configures the same dynamic module twice without a token, and the two calls differ in isGlobal or in what they configure, an application that imports that module fails at start() with OneBunConflictingRegistrationError, naming both call sites. Letting the call evaluated last decide for everyone would boot an application that wrote isGlobal: false with ambient injection anyway, or point two applications naming different databases at one of them. The check is per application — one that never imports the contested module boots normally — and two calls that agree stay silent.
Controller Decorators
@Controller()
Marks a class as an HTTP controller with a base path.
@Controller(basePath?: string)Example:
import { Controller, BaseController } from '@onebun/core';
@Controller('/api/users')
export class UserController extends BaseController {
// All routes will be prefixed with /api/users
}Note: Leading slash is optional.
@Controller('api/users')and@Controller('/api/users')are equivalent — the framework normalizes the path automatically.
HTTP Method Decorators
@Get(), @Post(), @Put(), @Delete(), @Patch(), @Options(), @Head(), @All()
Define HTTP endpoints on controller methods.
@Get(path?: string, options?: RouteOptions)
@Post(path?: string, options?: RouteOptions)
@Put(path?: string, options?: RouteOptions)
@Delete(path?: string, options?: RouteOptions)
@Patch(path?: string, options?: RouteOptions)
@Options(path?: string, options?: RouteOptions)
@Head(path?: string, options?: RouteOptions)
@All(path?: string, options?: RouteOptions)
interface RouteOptions {
/** Per-request idle timeout in seconds. Set to 0 to disable. */
timeout?: number;
}Note: The leading slash in the path argument is optional.
@Get('users')and@Get('/users')are equivalent. Similarly,@Get(':id')is the same as@Get('/:id'). This makes migration from NestJS seamless — NestJS-style paths work out of the box.
Per-request timeout:
Override the global idleTimeout for individual routes. Useful for long-running endpoints:
@Controller('/tasks')
export class TaskController extends BaseController {
@Post('/process', { timeout: 300 }) // 5 minutes for this endpoint
async processTask(@Body() body: unknown) {
// long-running task...
}
@Get('/export', { timeout: 0 }) // no timeout
async exportAll() {
// very long export...
}
}Path Parameters:
Use :paramName syntax for dynamic segments:
@Controller('/users')
export class UserController extends BaseController {
@Get('/') // GET /users
async findAll() {}
@Get('/:id') // GET /users/123
async findOne(@Param('id') id: string) {}
@Get('/:userId/posts') // GET /users/123/posts
async getUserPosts(@Param('userId') userId: string) {}
@Post('/') // POST /users
async create(@Body() body: CreateUserDto) {}
@Put('/:id') // PUT /users/123
async update(@Param('id') id: string, @Body() body: UpdateUserDto) {}
@Delete('/:id') // DELETE /users/123
async remove(@Param('id') id: string) {}
}@All() — catch-all routes
@All() is a true catch-all, like NestJS router.all(): the decorated handler answers every HTTP method on that path, not just the seven with a decorator of their own. That includes OPTIONS and HEAD, and it includes methods the framework has no decorator for at all — PROPFIND, PURGE, LOCK, QUERY, vendor verbs. This is what makes @All() usable for reverse proxies, webhook receivers and legacy-path shims.
@Controller('/gateway')
export class GatewayController extends BaseController {
@All('/proxy/:id')
async proxy(@Param('id') id: string, @Req() req: OneBunRequest) {
// req.method is whatever the client sent: GET, POST, PROPFIND, QUERY, ...
return { id, method: req.method };
}
}Precedence — an explicitly declared verb always wins. If the same path carries both @All() and a concrete verb decorator, the concrete one handles its verb and @All() handles everything else. The rule is fixed; it does not depend on the order the decorators are written in:
@Controller('/webhooks')
export class WebhookController extends BaseController {
@All('/github')
async fallback() {
return { handled: 'all' }; // PUT, DELETE, PROPFIND, ... land here
}
@Get('/github')
async health() {
return { handled: 'get' }; // GET lands here, not in fallback()
}
}CORS preflight. Because an
@All()path claimsOPTIONS, a preflight request reaches that route's middleware chain. When the application is configured withcors, the built-inCorsMiddlewareruns first and answers the preflight with204— the@All()handler body never runs. Withoutcorsconfigured, the@All()handler itself answersOPTIONS.
OpenAPI. An
@All()route has no single HTTP method, so it cannot be expressed as one OpenAPI operation. Document the individual verbs you care about with concrete decorators, or exclude the route from the generated spec.
Parameter Decorators
All parameter decorators support an options object to control whether the parameter is required:
interface ParamDecoratorOptions {
required?: boolean;
}@Param()
Extract path parameter from URL. Path parameters are always required per OpenAPI specification.
@Param(name: string, schema?: Type<unknown>)Example:
import { type } from '@onebun/core';
const idSchema = type('string.uuid');
@Get('/:id')
async findOne(
@Param('id') id: string, // Always required (OpenAPI spec)
@Param('id', idSchema) id: string, // With validation, always required
) {}@Query()
Extract query parameter from URL. Optional by default.
@Query(name: string, options?: ParamDecoratorOptions)
@Query(name: string, schema?: Type<unknown>, options?: ParamDecoratorOptions)Example:
// GET /users?page=1&limit=10
@Get('/')
async findAll(
@Query('page') page?: string, // Optional (default)
@Query('limit', { required: true }) limit: string, // Explicitly required
) {}
// With validation schema
@Get('/search')
async search(
@Query('q', type('string')) query?: string, // Optional with validation
@Query('sort', type('string'), { required: true }) sort: string, // Required with validation
) {}@Body()
Extract and optionally validate request body. Required is determined from schema - if the schema accepts undefined, the body is optional; otherwise it's required.
@Body(schema?: Type<unknown>, options?: ParamDecoratorOptions)Example:
import { type } from '@onebun/core';
const createUserSchema = type({
name: 'string',
email: 'string.email',
'age?': 'number > 0',
});
type CreateUserBody = typeof createUserSchema.infer;
// Schema doesn't accept undefined → required
@Post('/')
async create(
@Body(createUserSchema) body: CreateUserBody,
) {}
// Schema accepts undefined → optional
const optionalBodySchema = type({
name: 'string',
}).or(type.undefined);
type OptionalBody = typeof optionalBodySchema.infer;
@Post('/optional')
async createOptional(
@Body(optionalBodySchema) body: OptionalBody,
) {}
// Explicit override
@Post('/force-optional')
async forceOptional(
@Body(createUserSchema, { required: false }) body: CreateUserBody,
) {}
// Without validation - body is unknown
@Post('/simple')
async createSimple(
@Body() body: unknown,
) {}@Header()
Extract header value. Optional by default.
@Header(name: string, options?: ParamDecoratorOptions)
@Header(name: string, schema?: Type<unknown>, options?: ParamDecoratorOptions)Example:
@Get('/protected')
async protected(
@Header('X-Request-ID') requestId?: string, // Optional (default)
@Header('Authorization', { required: true }) auth: string, // Explicitly required
) {}
// With validation schema
@Get('/api')
async api(
@Header('X-API-Key', type('string'), { required: true }) apiKey: string,
) {}@Cookie()
Extract cookie value from request. Uses BunRequest.cookies (CookieMap) under the hood. Optional by default.
@Cookie(name: string, options?: ParamDecoratorOptions)
@Cookie(name: string, schema?: Type<unknown>, options?: ParamDecoratorOptions)Example:
// GET /api/me (with Cookie: session=abc123; theme=dark)
@Get('/me')
async getMe(
@Cookie('session') session?: string, // Optional (default)
@Cookie('session', { required: true }) session: string, // Explicitly required
) {}
// With validation schema
@Get('/prefs')
async prefs(
@Cookie('theme', type('"light" | "dark"')) theme?: string, // Optional with validation
) {}@Req()
Inject the raw request object. The type is OneBunRequest (alias for BunRequest), which extends the standard Web API Request with:
.cookies— aCookieMapfor reading and setting cookies.params— route parameters extracted by Bun's routes API
A handler parameter without a param decorator is not injected. What it receives depends on how the rest of the handler is written: on a route whose handler has no decorated parameter and no response schema the framework calls the handler with the request, so the first parameter happens to be it; on any other route the framework fills only the decorated positions and an undecorated one is undefined. Adding a single @Query() therefore flips the meaning of every other parameter on that handler. The framework reports each undecorated parameter at startup, by controller, handler and 0-based index — Function.length stops counting at the first defaulted or rest parameter, so a gap after one of those is not reported. Decorate the parameter with @Req() and the question does not arise.
@Req()Example:
import type { OneBunRequest } from '@onebun/core';
@Get('/raw')
async handleRaw(@Req() req: OneBunRequest) {
const url = new URL(req.url);
const headers = Object.fromEntries(req.headers);
// Access cookies via CookieMap
const session = req.cookies.get('session');
// Access route params (populated by Bun routes API)
// For route '/users/:id', req.params.id is available
}@Res() (deprecated)
Deprecated
@Res() is deprecated and currently injects undefined. Use return new Response(...) from your handler instead. Direct response manipulation is not supported — return a Response object to set custom headers, status codes, and cookies.
@Res()createHttpParamDecorator()
Build your own parameter decorator from a function of the execution context.
import { createHttpParamDecorator, getRequestContext } from '@onebun/core';
const CurrentUser = createHttpParamDecorator(() => getRequestContext()?.user);
@Get('/me')
me(@CurrentUser() user: AuthenticatedUser | undefined) {
return user;
}The extractor — a ParamExtractor, which is (context: HttpExecutionContext) => unknown — receives the same object a guard gets and returns the argument. It runs once per decorated parameter per request, and one context is built per request however many custom parameters a handler declares.
The case it exists for is per-request state a middleware or guard produced: the authenticated user, the tenant, a decoded token. Those live in the request context, which is the only place @Req() cannot reach. Anything an extractor could pull straight off the request is one property access away from @Req(), so a decorator that only does that buys an import rather than an abstraction.
Per-use arguments need no extra machinery — close over them:
const Claim = (name: string) => createHttpParamDecorator((ctx) => readClaim(ctx, name))();
@Get('/tenant')
tenant(@Claim('tid') tid: string) { return tid; }HTTP only
@UseGuards is the same decorator on all three transports; parameter decorators are not. A WebSocket handler reads its own metadata key with its own parameter types, and a queue handler has no parameter machinery at all — so a decorator built here on an @OnMessage or @Subscribe method is not an error, it is simply never read, and the parameter arrives undefined. The name says Http for that reason.
A custom parameter carries no schema and is never required: it is not validated, it does not appear in the OpenAPI document, and the generated service client does not take it as an argument. Validate inside the extractor if you need to. The extractor must be a pure view over data the request already carries — it runs on the request path and nothing unwinds what it allocates.
File Upload Decorators
Decorators for handling file uploads via multipart/form-data or JSON with base64-encoded data. The framework auto-detects the content type and provides a unified OneBunFile object.
@UploadedFile()
Extracts a single file from the request. Required by default.
@UploadedFile(fieldName?: string, options?: FileUploadOptions)FileUploadOptions:
interface FileUploadOptions {
/** Maximum file size in bytes */
maxSize?: number;
/** Allowed MIME types, supports wildcards like 'image/*'. Use MimeType enum. */
mimeTypes?: string[];
/** Whether the file is required (default: true) */
required?: boolean;
}Example:
import { Controller, Post, UploadedFile, MimeType, OneBunFile, BaseController } from '@onebun/core';
@Controller('/api/files')
export class FileController extends BaseController {
@Post('/avatar')
async uploadAvatar(
@UploadedFile('avatar', {
maxSize: 5 * 1024 * 1024,
mimeTypes: [MimeType.ANY_IMAGE],
}) file: OneBunFile,
) {
await file.writeTo(`./uploads/${file.name}`);
return { filename: file.name, size: file.size };
}
}@UploadedFiles()
Extracts multiple files from the request. Required by default (at least one file expected).
@UploadedFiles(fieldName?: string, options?: FilesUploadOptions)FilesUploadOptions:
interface FilesUploadOptions extends FileUploadOptions {
/** Maximum number of files allowed */
maxCount?: number;
}Example:
@Post('/documents')
async uploadDocs(
@UploadedFiles('docs', { maxCount: 10 }) files: OneBunFile[],
) {
for (const file of files) {
await file.writeTo(`./uploads/${file.name}`);
}
return { count: files.length };
}
// All files from request (no field name filter)
@Post('/batch')
async uploadBatch(
@UploadedFiles(undefined, { maxCount: 20 }) files: OneBunFile[],
) {
return { count: files.length };
}@FormField()
Extracts a non-file form field from the request. Optional by default.
@FormField(fieldName: string, options?: ParamDecoratorOptions)Example:
@Post('/profile')
async createProfile(
@UploadedFile('avatar', { mimeTypes: [MimeType.ANY_IMAGE] }) avatar: OneBunFile,
@FormField('name', { required: true }) name: string,
@FormField('email') email: string,
) {
await avatar.writeTo(`./uploads/${avatar.name}`);
return { name, email, avatar: avatar.name };
}OneBunFile
Unified file wrapper returned by @UploadedFile and @UploadedFiles. Works the same regardless of upload method (multipart or JSON+base64).
class OneBunFile {
readonly name: string; // File name
readonly size: number; // File size in bytes
readonly type: string; // MIME type
readonly lastModified: number; // Last modified timestamp
async toBase64(): Promise<string>; // Convert to base64 string
async toBuffer(): Promise<Buffer>; // Convert to Buffer
async toArrayBuffer(): Promise<ArrayBuffer>; // Convert to ArrayBuffer
toBlob(): Blob; // Get underlying Blob
async writeTo(path: string): Promise<void>; // Write to disk
static fromBase64(data: string, filename?: string, mimeType?: string): OneBunFile;
}MimeType Enum
Common MIME types for use with file upload options:
import { MimeType } from '@onebun/core';
// Wildcards
MimeType.ANY // '*/*'
MimeType.ANY_IMAGE // 'image/*'
MimeType.ANY_VIDEO // 'video/*'
MimeType.ANY_AUDIO // 'audio/*'
// Images
MimeType.PNG, MimeType.JPEG, MimeType.GIF, MimeType.WEBP, MimeType.SVG
// Documents
MimeType.PDF, MimeType.JSON, MimeType.XML, MimeType.ZIP, MimeType.CSV, MimeType.XLSX, MimeType.DOCX
// Video/Audio
MimeType.MP4, MimeType.WEBM, MimeType.MP3, MimeType.WAV
// Text
MimeType.PLAIN, MimeType.HTML, MimeType.CSS, MimeType.JAVASCRIPT
// Binary
MimeType.OCTET_STREAMJSON Base64 Upload Format
When sending files via application/json, the framework accepts two formats:
// Full format with metadata
{ "avatar": { "data": "iVBORw0KGgo...", "filename": "photo.png", "mimeType": "image/png" } }
// Simplified format (raw base64 string)
{ "avatar": "iVBORw0KGgo..." }The same @UploadedFile decorator works for both multipart and JSON uploads.
WARNING
@Body() cannot be used together with @UploadedFile, @UploadedFiles, or @FormField on the same method, since both consume the request body.
Service Decorators
@Service()
Marks a class as an injectable service.
@Service(tag?: Context.Tag<T, T>)Example:
import { Service, BaseService } from '@onebun/core';
@Service()
export class UserService extends BaseService {
// Service with auto-generated tag
async findAll(): Promise<User[]> {
this.logger.info('Finding all users');
// ...
}
}
// With custom Effect.js tag
import { Context } from 'effect';
const CustomServiceTag = Context.GenericTag<CustomService>('CustomService');
@Service(CustomServiceTag)
export class CustomService extends BaseService {
// Service with explicit tag
}@Inject()
Explicit dependency injection for edge cases. In most cases, automatic DI works without this decorator. See Architecture — Dependency Injection for how DI resolution works.
@Inject(type: new (...args: any[]) => T)
@Inject(token: symbol | string)When to use @Inject:
- A parameter typed as an interface — pass the CONCRETE class, never the interface itself. An interface has no runtime value, so
@Inject(SomeInterface)does not compile, and an interface-typed parameter with no@Injectreceivesundefined - Token-based injection (custom Context.Tag)
- Overriding automatic resolution
- Picking WHICH named registration a parameter gets, in a module that selected two of them
An abstract-class-typed parameter needs no @Inject: DI resolves it to a registered subclass on its own, and @Inject(AbstractClass) is a compile error — see Architecture for the exact diagnostics.
An unresolvable parameter is undefined, in its own slot
A parameter the container cannot name — an interface, any, unknown, a type alias — receives undefined and the framework logs which parameter index it was. It does not shift the parameters after it; those keep their own values. When a parameter that does resolve follows it, the index is also reported as a startup warn; a trailing one is logged at debug only.
This is about the ARGUMENT the constructor receives. The design:paramtypes entry for that same parameter is Object, not undefined — see Auditing constructor parameters before writing a sweep over your own codebase.
Example:
@Controller('/users')
export class UserController extends BaseController {
constructor(
// Automatic injection (works in most cases) - no @Inject needed
private userService: UserService,
private cacheService: CacheService,
// @Inject needed only for edge cases:
// - When the parameter is typed as an INTERFACE: name the implementation here, because
// the interface itself has no runtime value for the container to look up
// - When using custom Effect.js Context.Tag
@Inject(EmailNotifier) private notifier: Notifier,
) {
super();
}
}Selecting a named registration:
Only needed in a module that imported TWO registrations of one service. A module that selected one resolves it by type, with no annotation at all.
@Module({
imports: [
DrizzleModule.forFeature(MAIN_DB),
DrizzleModule.forFeature(ANALYTICS_DB),
],
providers: [Reconciler],
})
export class ReconcileModule {}
@Service()
export class Reconciler extends BaseService {
constructor(
@Inject(MAIN_DB) private main: DrizzleService,
@Inject(ANALYTICS_DB) private analytics: DrizzleService,
private clock: ClockService, // un-annotated parameters resolve normally
) {
super();
}
}Asking for a token the module never selected throws at startup, naming what it did select — the alternative is being handed the other database, silently.
@Optional()
Marks a constructor parameter as optional for dependency injection. When the dependency cannot be resolved, undefined is injected instead of throwing DependencyResolutionError.
@Optional()When to use @Optional:
- Feature-gated dependencies (e.g., caching that may not be configured)
- Graceful degradation when an optional module is not imported
A dependency listed in the module's own providers that was never constructed — its constructor threw, or this copy of @onebun/core sees no @Service() on it — counts as not available too: the parameter receives undefined, a warning names the dependency and why it has no instance, and the boot goes on.
An @Optional() parameter typed as an abstract class does not wait for its implementation the way every other parameter does: it receives the subclass only if that is already built when its consumer is constructed, and undefined with a warning otherwise. List the implementation, and what it injects, before the consumer. Waiting would turn an implementation that injects its consumer back — which boots, with undefined — into a CircularDependencyError.
Example:
@Service()
export class NotificationService extends BaseService {
constructor(
private userService: UserService, // required — throws if missing
@Optional() private emailService?: EmailService, // optional — undefined if missing
) {
super();
}
async notify(userId: string, message: string) {
if (this.emailService) {
await this.emailService.send(userId, message);
} else {
this.logger.warn('EmailService not available, skipping email notification');
}
}
}Middleware Decorators
@Middleware()
Class decorator for middleware. Apply it to classes that extend BaseMiddleware so that the framework can resolve constructor dependencies automatically (TypeScript emits design:paramtypes when the class has a decorator). Without @Middleware(), you would need @Inject() on each constructor parameter for DI to work.
@Middleware()
class AuthMiddleware extends BaseMiddleware {
constructor(private authService: AuthService) {
super();
}
async use(req, next) { ... }
}@UseMiddleware()
Apply middleware to a route handler or to all routes in a controller. Works as both a method decorator and a class decorator. Pass class constructors extending BaseMiddleware (not instances).
// Method decorator — applies to a single route
@UseMiddleware(...middleware: MiddlewareClass[])
// Class decorator — applies to every route in the controller
@UseMiddleware(...middleware: MiddlewareClass[])Method-level example:
import { BaseMiddleware, type OneBunRequest, type OneBunResponse } from '@onebun/core';
class AuthMiddleware extends BaseMiddleware {
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
const token = req.headers.get('Authorization');
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
return next();
}
}
class LogMiddleware extends BaseMiddleware {
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
this.logger.info(`${req.method} ${req.url}`);
return next();
}
}
@Controller('/users')
export class UserController extends BaseController {
@Get('/protected')
@UseMiddleware(AuthMiddleware)
async protectedRoute() {
return { message: 'Secret data' };
}
@Post('/action')
@UseMiddleware(LogMiddleware, AuthMiddleware) // Multiple middleware
async action() {
return { message: 'Action performed' };
}
}Class-level example:
@Controller('/admin')
@UseMiddleware(AuthMiddleware) // Applied to ALL routes in this controller
export class AdminController extends BaseController {
@Get('/dashboard')
getDashboard() {
return { stats: {} };
}
@Put('/settings')
@UseMiddleware(AuditLogMiddleware) // Additional middleware for this route
updateSettings() {
return { updated: true };
}
}When both class-level and method-level middleware are present, execution order is: controller-level -> route-level -> handler.
Middleware classes support full DI through the constructor. Use @Middleware() on the class for automatic dependency resolution. See Controllers API — Middleware for details and examples.
Response Decorators
@ApiResponse()
Define response schema for documentation and validation.
@ApiResponse(statusCode: number, options?: {
schema?: Type<unknown>;
description?: string;
})Example:
import { type } from '@onebun/core';
const userResponseSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
});
@Controller('/users')
export class UserController extends BaseController {
@Get('/:id')
@ApiResponse(200, {
schema: userResponseSchema,
description: 'User found successfully',
})
@ApiResponse(404, {
description: 'User not found',
})
async findOne(@Param('id') id: string) {
// Response will be validated against userResponseSchema
return { id, name: 'John', email: 'john@example.com' };
}
}The schema is enforced, not merely documented: a body that violates it never reaches the client. The request answers HTTP 500 with the default filter's fixed { success: false, error: 'Internal Server Error', code: 500 } — a response that does not match its own declared schema is a server-side contract bug, and the violation detail quotes the offending value, so it goes to the application log rather than to the caller.
Documentation Decorators
Package: @onebun/docs
These decorators add metadata for OpenAPI/Swagger documentation generation.
@ApiTags()
Group endpoints under tags for documentation organization.
import { ApiTags } from '@onebun/docs';
@ApiTags(...tags: string[])Can be used on controller class or individual methods:
Example:
import { Controller, BaseController, Get } from '@onebun/core';
import { ApiTags } from '@onebun/docs';
@ApiTags('Users', 'User Management')
@Controller('/users')
export class UserController extends BaseController {
// All endpoints tagged with 'Users' and 'User Management'
@ApiTags('Admin')
@Get('/admins')
async getAdmins() {
return [];
}
}@ApiOperation()
Describe an API operation with summary, description, and additional tags.
import { ApiOperation } from '@onebun/docs';
@ApiOperation(options: {
summary?: string;
description?: string;
tags?: string[];
})Example:
import { Controller, BaseController, Get, Param } from '@onebun/core';
import { ApiOperation } from '@onebun/docs';
@Controller('/users')
export class UserController extends BaseController {
@ApiOperation({
summary: 'Get user by ID',
description: 'Returns a single user by their unique identifier. Returns 404 if user not found.',
tags: ['Users'],
})
@Get('/:id')
async getUser(@Param('id') id: string) {
return { id, name: 'John' };
}
}Combining Documentation Decorators
Use both @onebun/core and @onebun/docs decorators together for complete documentation:
import { Controller, BaseController, Get, Post, Body, Param, ApiResponse, type } from '@onebun/core';
import { ApiTags, ApiOperation } from '@onebun/docs';
const userSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
});
const createUserSchema = type({
name: 'string',
email: 'string.email',
});
type CreateUserBody = typeof createUserSchema.infer;
@ApiTags('Users')
@Controller('/users')
export class UserController extends BaseController {
@ApiOperation({ summary: 'Get user by ID' })
@Get('/:id')
@ApiResponse(200, { schema: userSchema, description: 'User found' })
@ApiResponse(404, { description: 'User not found' })
async getUser(@Param('id') id: string) {
// ...
}
@ApiOperation({ summary: 'Create new user', description: 'Creates a new user account' })
@Post('/')
@ApiResponse(201, { schema: userSchema, description: 'User created' })
@ApiResponse(400, { description: 'Invalid input' })
async createUser(@Body(createUserSchema) body: CreateUserBody) {
// ...
}
}Tracing Decorators
@Span()
Create a trace span for a method (from @onebun/trace).
@Span(name?: string)Example:
import { Span } from '@onebun/trace';
@Service()
export class UserService extends BaseService {
@Span('user-find-by-id')
async findById(id: string): Promise<User | null> {
// This method is automatically traced
return this.repository.findById(id);
}
@Span() // Default span name is `ClassName.methodName`
async processUser(user: User): Promise<void> {
// Span name: "UserService.processUser" — the class prefix is part of it
}
}Utility Functions
getControllerMetadata()
Get metadata for a controller class.
function getControllerMetadata(target: Function): ControllerMetadata | undefined;
interface ControllerMetadata {
path: string;
routes: RouteMetadata[];
}
interface RouteMetadata {
path: string;
method: HttpMethod;
handler: string;
params?: ParamMetadata[];
middleware?: Function[];
responseSchemas?: ResponseSchemaMetadata[];
}getModuleMetadata()
Get metadata for a module class.
function getModuleMetadata(target: Function): ModuleMetadata | undefined;
interface ModuleMetadata {
imports?: Function[];
controllers?: Function[];
providers?: unknown[];
exports?: unknown[];
}getServiceMetadata()
Get metadata for a service class.
function getServiceMetadata(serviceClass: Function): ServiceMetadata | undefined;
interface ServiceMetadata {
tag: Context.Tag<unknown, unknown>;
impl: new () => unknown;
}getServiceTag()
Get Effect.js Context tag for a service class.
function getServiceTag<T>(serviceClass: new (...args: unknown[]) => T): Context.Tag<T, T>;registerDependencies()
Manually register constructor dependencies (fallback method).
function registerDependencies(target: Function, dependencies: (Function | undefined)[]): void;Auditing constructor parameters
function getConstructorParamTypes(target: Function): (Function | undefined)[] | undefined;
function isInjectableParamType(type: Function | undefined): type is Function;getConstructorParamTypes returns the array positionally intact: entry i describes parameter i. It returns undefined only when no metadata was emitted at all.
An entry is undefined when the slot held something that is not a constructor. It is notundefined for a parameter typed as an interface: Bun's emitDecoratorMetadata emits every type reference as typeof X === "undefined" ? Object : X, so an interface, a type alias, any, unknown and a reference broken by a circular import all arrive as Object. An audit spelled types.filter((type) => type === undefined) therefore reports zero holes on every application ever written.
isInjectableParamType is the predicate the container itself uses, exported so the audit asks the framework instead of guessing:
import { getConstructorParamTypes, isInjectableParamType } from '@onebun/core';
const types = getConstructorParamTypes(MyService) ?? [];
const holes = types.flatMap((type, index) => (isInjectableParamType(type) ? [] : [index]));Two things it deliberately does not do. It does not say why a slot is unusable — Object cannot be told apart from any or a circular import — and it does not treat Function, Array, Symbol, Date, Map or Set as holes: each of those reaches the resolver and raises a loud DependencyResolutionError at startup, and calling them holes here would convert those failures into silent undefined injections. The non-injectable set is exactly Object, String, Number and Boolean.
The framework warns at startup about a hole followed by a later parameter that resolves — the shape in which a missing dependency is easiest to overlook, since every other field is filled; a trailing hole is far more often a deliberate optional and is logged at debug only. See capturing framework logs for reading the warning from a test. A parameter named by @Inject(TOKEN) still shows as Object here, because the token lives in a side map keyed by parameter index, not in design:paramtypes.
Use the top-level getConstructorParamTypes, not Reflect.getConstructorParamTypes. The one on the Reflect namespace is the raw metadata reader and does not merge @Inject(ConcreteClass), so an audit written against it reports a hole for every parameter that an explicit @Inject already answered.
Reflect Metadata and Other Libraries
Bun records constructor and member types (design:paramtypes, design:type) through the global Reflect.metadata, so importing @onebun/core installs the Reflect Metadata API on the global Reflect: all nine functions — metadata, defineMetadata, hasMetadata, hasOwnMetadata, getMetadata, getOwnMetadata, getMetadataKeys, getOwnMetadataKeys and deleteMetadata — with per-member storage and the proposal's prototype walk. The core does not depend on reflect-metadata and does not need it.
The same global is shared with every other library that uses it: tsyringe, class-transformer, inversify, and anything that imports reflect-metadata on its own (@simplewebauthn/server does, through tsyringe). Any import order works:
reflect-metadataimported before the core — the core finds a complete implementation and uses it as it is.reflect-metadataimported after the core — a 0.2.x copy joins the provider registry the core publishes, so everything decorated before it arrived is still read back; a 0.1.x copy finds nothing missing and changes nothing.
If your application imports reflect-metadata itself, use 0.2.2 or later. 0.2.1 loses member metadata of classes decorated before it loaded whenever it has to adopt an older implementation that has no registry (a 0.1.x copy loaded earlier, for example).
Constructor types are read own, never inherited. A decorated subclass that declares no constructor of its own has no design:paramtypes of its own, so it is built with no dependencies, and the module logs a warning at startup that names the class, the parent and the types it did not get. Declare the constructor again to have them injected:
import { BaseService, getConstructorParamTypes, Service } from '@onebun/core';
@Service()
export class Clock extends BaseService {}
@Service()
export class Scheduler extends BaseService {
constructor(protected readonly clock: Clock) {
super();
}
}
// No constructor of its own: nothing is injected, `this.clock` is undefined,
// and startup warns "NightlyScheduler declares no constructor of its own ..."
@Service()
export class NightlyScheduler extends Scheduler {}
// The constructor declared again: `clock` is injected
@Service()
export class HourlyScheduler extends Scheduler {
constructor(clock: Clock) {
super(clock);
}
}
getConstructorParamTypes(Scheduler); // [Clock]
getConstructorParamTypes(NightlyScheduler); // undefined
getConstructorParamTypes(HourlyScheduler); // [Clock]The parent's types are not borrowed because a subclass may take different parameters: a guard without a decorator that extends a decorated base guard and declares constructor(audit: AuditLog) would otherwise receive the base's first dependency in its audit slot. An undecorated class emits no metadata at all, so it is built with no dependencies — add a decorator (@Service(), or @Middleware() for middleware) to have its own constructor injected. When such a class declares constructor parameters and extends a class that takes dependencies, startup warns about it too: <Class> declares a constructor with parameters, but no types were emitted for them .... One whose constructor takes no parameters — constructor() { super(new TokenStore()); } — records nothing either, so it cannot be told from a class that declares none: it gets the first warning, worded for both (<Class> declares no constructor of its own (or, without a decorator, one whose parameter types were not recorded) ...), which is harmless when that constructor passes the parent's dependencies itself.
The import order of reflect-metadata does not change this. The core reads constructor types with getOwnMetadata, never with the prototype-walking getMetadata, so a subclass with no constructor types of its own gets none of its parent's even when reflect-metadata (or a library that imports it, such as @simplewebauthn/server) is imported BEFORE the core. The app still boots, so the two startup warnings above are how to find each class this affects:
<Class> declares no constructor of its own ...— a subclass without a constructor gets none of its parent's dependencies, whether it is decorated or an undecorated guard, interceptor, filter or middleware. Declare the constructor in it and pass them tosuper(...). An undecorated one needs a decorator as well —@Service(), or@Middleware()for middleware — or the constructor it declares records no types either and the next warning takes over.<Class> declares a constructor with parameters, but no types were emitted ...— an undecorated guard, interceptor, filter or middleware that extends a decorated class and declares its own constructor receives no types at all, not even the parent's by position. Add@Service()to it, or@Middleware()if it is middleware.
Complete Example
import {
Module,
Controller,
BaseController,
Service,
BaseService,
Get,
Post,
Param,
Body,
Query,
Header,
UseMiddleware,
BaseMiddleware,
ApiResponse,
HttpException,
type OneBunRequest,
type OneBunResponse,
type,
} from '@onebun/core';
import { Span } from '@onebun/trace';
// Validation schemas (in a real app, these live in a separate schema file)
const createUserSchema = type({
name: 'string',
email: 'string.email',
});
type CreateUserBody = typeof createUserSchema.infer;
const userSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
});
type User = typeof userSchema.infer;
// Service
@Service()
export class UserService extends BaseService {
private users = new Map<string, User>();
@Span('find-all-users')
async findAll(): Promise<User[]> {
return Array.from(this.users.values());
}
@Span('find-user-by-id')
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async create(data: CreateUserBody): Promise<User> {
const user = { id: crypto.randomUUID(), ...data };
this.users.set(user.id, user);
this.logger.info('User created', { userId: user.id });
return user;
}
}
// Middleware
class AuthMiddleware extends BaseMiddleware {
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
const token = req.headers.get('Authorization');
if (!token?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ success: false, message: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return next();
}
}
// Controller
@Controller('/users')
export class UserController extends BaseController {
constructor(private userService: UserService) {
super();
}
@Get('/')
@ApiResponse(200, { schema: userSchema.array() })
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.userService.findAll();
}
@Get('/:id')
@ApiResponse(200, { schema: userSchema })
@ApiResponse(404, { description: 'User not found' })
async findOne(@Param('id') id: string) {
const user = await this.userService.findById(id);
if (!user) throw new HttpException(404, 'User not found');
return user;
}
@Post('/')
@UseMiddleware(AuthMiddleware)
@ApiResponse(201, { schema: userSchema })
async create(
@Body(createUserSchema) body: CreateUserBody,
@Header('X-Request-ID') requestId?: string,
) {
this.logger.info('Creating user', { requestId });
return this.userService.create(body);
}
}
// Module
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}