A collection of TypeScript decorators, Express middlewares, and utility functions for building REST APIs with Bun, Express and Prisma — less boilerplate, more joy.
bun add @mateusseiboth/ts-decoratorsbun add typescript reflect-metadata express @types/express zodLegacy decorators (recommended):
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}TC39 Stage 3 (TypeScript 5+, without emitDecoratorMetadata):
Works, but with important restrictions — see the @Field / @InitFields section.
This package revolves around a field metadata system that connects everything:
@Field() → registers the name and type of each field
@InitFields → initialises the registry on the class (REQUIRED)
@ModelTagged → puts the class in a global registry keyed by numeric tag
(requires @InitFields to have been applied first)
getWhere → uses the registry + collectFieldTypes to know which
getOrderBy query params are valid and what type each field is
@AutoConvert → uses getFieldTypes(this) to coerce incoming values
(the class or its model MUST have @InitFields + @Field)
collectFieldTypes → uses getFieldTypes + getNestedModel recursively
(ALL classes involved MUST have @InitFields)
Golden rule: if a field does not have
@Field(), it does not exist for the system. Without@InitFieldson the class, no fields are found — no error is thrown, the result is simply empty/ignored.
Converts Express query params into a Prisma where clause, populating res.locals.where.
⚠️ Required dependencies:
- The model passed as the 4th argument MUST have
@ModelTagged(to be in the registry)- The model MUST have
@InitFields(so its fields can be found)- Only fields decorated with
@Field()are recognised as valid filters
— query params for fields without@Field()are silently ignored- For nested filters (
?street.name=...), the relation field MUST have@NestedModel(Model)AND the related model MUST also have@InitFields+@Field()on its fields
import {getWhere} from "@mateusseiboth/ts-decorators";
// Pass the MODEL CLASS (not an instance) — it needs static tag + @ModelTagged
router.get("/", (req, res, next) => getWhere(req, res, next, StreetModel), handler);
// → res.locals.where is ready for PrismaYou can pass extra fixed conditions (not from the query) as the 5th argument:
router.get("/", (req, res, next) => getWhere(req, res, next, StreetModel, {active: true}), handler);
// → appends AND: [{ active: true }] to the generated whereSupported operators (via : prefix):
| Query syntax | Prisma equivalent |
|---|---|
?name=John |
{ name: { equals: "John" } } |
?name=contains:oh |
{ name: { contains: "oh" } } |
?name=startsWith:Jo |
{ name: { startsWith: "Jo" } } |
?name=endsWith:hn |
{ name: { endsWith: "hn" } } |
?status=in:1,2,3 |
{ status: { in: [1, 2, 3] } } |
?age=inRange:18-65 |
{ age: { gte: 18, lte: 65 } } |
?age=greaterThan:18 |
{ age: { gt: 18 } } |
?age=lessThan:30 |
{ age: { lt: 30 } } |
?age=greaterThanOrEqual:18 |
{ age: { gte: 18 } } |
?age=lessThanOrEqual:30 |
{ age: { lte: 30 } } |
?field=isNull: |
{ field: null } |
?field=notNull: |
{ field: { not: null } } |
?id=1;2;3 |
OR: [{ id: 1 }, { id: 2 }, { id: 3 }] |
?name=!John |
NOT: [{ name: "John" }] |
?id=5-10 (numeric field) |
OR: [{ id: 5 }, ..., { id: 10 }] (expands) |
?district.name=Downtown |
{ district: { name: "Downtown" } } |
ℹ️ Type coercion: query values are automatically cast to the field's type as registered via
@Field(). If the field is"number", the string"5"becomes5.
Programmatic version (without Express):
import {buildWhereFromQuery} from "@mateusseiboth/ts-decorators";
// Takes the query object and a MODEL INSTANCE
const where = buildWhereFromQuery(req.query, new StreetModel());Reads req.query.orderBy and req.query.orderMethod, validates them against the model's fields and populates res.locals.orderBy.
⚠️ Required dependencies:
- The model MUST have
@ModelTagged(to be in the registry)- The model MUST have
@InitFields- Only fields with
@Field()are valid asorderBy— invalid values result inres.locals.orderBy = [](empty array), not an error
import {getOrderBy} from "@mateusseiboth/ts-decorators";
// Signature: (req, res, next, ModelClass) — requires a class with static tag
router.get("/", (req, res, next) => getOrderBy(req, res, next, StreetModel), handler);
// GET /streets?orderBy=name&orderMethod=asc
// → res.locals.orderBy = [{ name: "asc" }]
// GET /streets?orderBy=nonExistentField&orderMethod=asc
// → res.locals.orderBy = [] (silent — field has no @Field decorator)ℹ️ If either
orderByororderMethodis absent from the query, the middleware callsnext()without settingres.locals.orderBy.makePrismaOptionstreatsundefinedas "no ordering".
Reads HTTP headers (not query params) and populates res.locals.paginate.
⚠️ Pagination data comes from request HEADERS, not query params.
import {getPaginate} from "@mateusseiboth/ts-decorators";
router.get("/", getPaginate, handler);| Header | Type | Default | Description |
|---|---|---|---|
paginate |
string | — | must be "true" to enable pagination |
page |
string | "1" |
page number (1-based) |
offset |
string | "10" |
number of records per page |
Headers: paginate: "true", page: "2", offset: "20"
→ res.locals.paginate = { skip: 20, take: 20, page: 2 }
// skip = (page - 1) * offset = (2 - 1) * 20 = 20
ℹ️ If the
paginateheader is not"true",res.locals.paginatestaysundefinedandmakePrismaOptionsomitsskip/takefrom the Prisma query (returns all records).
The foundation of the entire metadata system. Every other feature that needs to know your model's field names or types depends on these two decorators.
⚠️ @InitFieldsMUST be applied to every model class.@Field()MUST decorate every property that should be visible togetWhere,getOrderBy,@AutoConvert, andcollectFieldTypes. Fields without@Field()are completely invisible to the system.
import {Field, InitFields} from "@mateusseiboth/ts-decorators";
@InitFields // REQUIRED — initialises the field registry for this class
class DistrictModel {
@Field() id!: string; // type inferred from reflect-metadata (legacy only)
@Field() name!: string;
@Field("number") code!: number; // type explicit — required when using TC39 decorators
@Field() active!: boolean;
@Field() createdAt!: Date;
// ❌ this field does NOT exist for getWhere/getOrderBy/@AutoConvert:
internalNotes!: string;
}Legacy vs TC39 type inference:
| Mode | @Field() |
@Field("number") |
|---|---|---|
Legacy (emitDecoratorMetadata: true) |
Type is auto-inferred via reflect-metadata |
Works (explicit overrides) |
TC39 Stage 3 (no emitDecoratorMetadata) |
Type defaults to "any" — always pass it explicitly |
Required |
getFieldTypes(instance) — returns all registered fields as Record<fieldName, typeName>:
import {getFieldTypes} from "@mateusseiboth/ts-decorators";
getFieldTypes(new DistrictModel());
// → { id: "string", name: "string", code: "number", active: "boolean", createdAt: "date" }
// ⚠️ "internalNotes" is NOT here — it has no @Field()getFieldTypeByKey(instance, key) — returns the type of a single field, or undefined if not decorated:
import {getFieldTypeByKey} from "@mateusseiboth/ts-decorators";
getFieldTypeByKey(new DistrictModel(), "code"); // → "number"
getFieldTypeByKey(new DistrictModel(), "internalNotes"); // → undefinedℹ️ Inheritance:
getFieldTypeswalks the prototype chain, so a subclass automatically inherits the@Field()registrations of its parent class.
A global runtime registry that maps numeric tags to Model and DAO classes.
⚠️ Required dependencies:
- The class MUST have
@InitFieldsapplied before@ModelTaggedin the decorator list (decorators are applied bottom-up, so@InitFieldsshould appear below@ModelTagged)- The class MUST have a
static tagproperty- Duplicate tags cause
process.exit(1)at startup — tags must be unique across the entire application@DAOFor(tag)throws if the corresponding model tag has not been registered yet
import {ModelTagged, DAOFor, getModel, getDAO, getAllModels} from "@mateusseiboth/ts-decorators";
// Decorator order matters: @ModelTagged runs first (bottom-up), @InitFields runs second
@ModelTagged // ← 2nd to run
@InitFields // ← 1st to run
class DistrictModel {
static tag = 7770;
@Field() id!: string;
@Field() name!: string;
}
@DAOFor(DistrictModel.tag) // links DAO.model = DistrictModel
class DistrictDAO {
// DistrictDAO.model === DistrictModel
}
getModel(7770); // → DistrictModel
getDAO(7770); // → DistrictDAO
getAllModels(); // → [DistrictModel, ...]
getAllDAOs(); // → [DistrictDAO, ...]Registers a field as a relation to another Model, enabling dot-notation nested filters in getWhere (e.g. ?district.name=Downtown) and recursive field traversal in collectFieldTypes.
⚠️ Required dependencies:
- The field MUST also have
@Field()—@NestedModelalone does not register the field in the type system- The related model MUST have
@InitFields+@Field()on its fields, otherwise nested filters andcollectFieldTypeswill return nothing for that relation- The related model MUST have
@ModelTaggedif you intend to usegetWhere/getOrderByon it directly
import {Field, InitFields, ModelTagged, NestedModel} from "@mateusseiboth/ts-decorators";
@ModelTagged
@InitFields
class StreetModel {
static tag = 8880;
@Field() id!: string;
@Field() name!: string;
// Both @Field and @NestedModel are required:
@Field() // registers "district" as a known field
@NestedModel(DistrictModel) // tells the system it's a nested model
district?: DistrictModel;
// ❌ only @NestedModel without @Field — district2 won't appear in getWhere at all:
@NestedModel(DistrictModel)
district2?: DistrictModel;
}With this setup, ?district.name=Downtown in getWhere correctly produces { district: { name: "Downtown" } }.
Automatically coerces values in the first argument (data) of a method to the types declared with @Field() on the current instance's class.
⚠️ Required dependencies:
- The class (or a parent in its prototype chain) MUST have
@InitFieldsand@Field()decorators@AutoConvertreads the field types fromthisat runtime — the method must be called on an instance that has the metadata- Keys in
datathat do not have a corresponding@Field()are left untouchednull/undefinedvalues are always passed through asnull(no conversion attempted)
import {AutoConvert, Field, InitFields} from "@mateusseiboth/ts-decorators";
@InitFields
class ProductModel {
@Field() name!: string;
@Field("number") price!: number;
@Field() active!: boolean;
@Field() createdAt!: Date;
}
class ProductController {
model = new ProductModel();
@AutoConvert
async create(data: Record<string, any>) {
// Before: data = { name: "Bolt", price: "9.99", active: "true", createdAt: "2026-01-01" }
// After: data = { name: "Bolt", price: 9.99, active: true, createdAt: Date(...) }
}
}Conversion rules:
| Field type | Input | Output |
|---|---|---|
"number" |
"9.99" |
9.99 |
"number" |
"abc" |
null (NaN) |
"bigint" |
"123" |
123n |
"bigint" |
"abc" |
null |
"boolean" |
"false"/"0"/"N"/0 |
false |
"boolean" |
"true"/"1"/"S"/1 |
true |
"boolean" |
anything else | Boolean(val) |
"date" |
"2026-01-01" |
Date(...) |
"date" |
0 or "0" |
null |
"date" |
invalid string | null |
"string" |
42 |
"42" |
In-memory cache with per-entity isolation and automatic invalidation on write methods. No external dependencies.
import { Cacheable, CacheMethod, clearAllCache, getCacheStats } from "@mateusseiboth/ts-decorators";
@Cacheable({
ttl: 30_000, // time-to-live in ms
readMethods: ["get", "list"], // methods that read from cache
writeMethods: ["create", "update", "deleteById"], // methods that invalidate cache
})
class DistrictController {
async get(req, res) { /* sets X-Cache: HIT or MISS */ }
async create(req, res) { /* invalidates all cache entries for this entity */ }
@CacheMethod({ ttl: 60_000 }) // per-method TTL override
async findSpecial(req, res) { ... }
}Cache key is built from: HTTP method, baseUrl, path, route params, query string, pagination headers, and res.locals.where/orderBy/paginate.
Entity isolation: cache entries are partitioned by className → res.locals.entity. Writing to one entity never invalidates another.
Limits: maxEntries: 200 per entity — oldest entries are evicted when exceeded.
Testing helpers:
clearAllCache(); // wipes everything
getCacheStats(); // → { DistrictController: 12, StreetController: 5 }In-memory sliding window rate limiter. No Redis required.
import { RateLimit, RateLimitMethod, rateLimitMiddleware, clearRateLimitStore } from "@mateusseiboth/ts-decorators";
// Applied to an entire controller
@RateLimit({ maxRequests: 100, windowMs: 60_000 })
class MyController { ... }
// Applied to a single method
class MyController {
@RateLimitMethod({ maxRequests: 10, windowMs: 60_000 })
async create(req, res) { ... }
}
// As a plain Express middleware
router.use(rateLimitMiddleware({ maxRequests: 100, windowMs: 60_000 }));Returns HTTP 429 when the limit is exceeded, with the following response headers:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed in the window |
X-RateLimit-Remaining |
Requests remaining in the current window |
X-RateLimit-Reset |
Unix timestamp when the window resets |
Retry-After |
Seconds until the client may retry |
The default rate-limit key is IP:entity. Provide a custom keyGenerator function to change this:
@RateLimit({
maxRequests: 50,
windowMs: 60_000,
keyGenerator: (req, res) => `${req.ip}:${res.locals.userId}`,
})
class MyController { ... }Testing helper:
clearRateLimitStore(); // resets all countersAutomatically records an audit trail for create, update, and deleteById calls, writing to an audit_log table after each successful operation. The write is fire-and-forget (does not block the response).
import { Auditable, setAuditStorage } from "@mateusseiboth/ts-decorators";
@Auditable
class OrderController {
async create(req, res) { ... }
async update(req, res) { ... }
async deleteById(req, res) { ... }
}Each audit entry contains:
| Field | Source |
|---|---|
action |
"CREATE" / "UPDATE" / "DELETE" |
entity |
Class name without "Controller" |
entityId |
From req.params.id |
userId |
From res.locals.userInfo.id |
entidade |
From res.locals.entity |
before |
Fetched via prismaModel.findUnique before the operation |
after |
Captured from the intercepted res.json payload |
timestamp |
new Date() at time of write |
method |
"ClassName.methodName" |
ip |
req.ip |
Override the default storage adapter (e.g. for tests or a different persistence layer):
setAuditStorage(async (entry, tx) => {
await myCustomLogger.write(entry);
});Wraps every method in the class to prefix all console.log calls with a timestamp and the ClassName -> methodName path.
import {logDecorator} from "@mateusseiboth/ts-decorators";
@logDecorator
class MyController {
async get(req, res) {
console.log("fetching records...");
// → "[12/04/2026 14:32:01 - MyController -> get] fetching records..."
}
}The decorator replaces console.log with a prefixed version for the duration of each method call, then restores the global _console reference afterwards. Other console methods (warn, error, etc.) are not affected.
Wraps an Express handler in a Prisma transaction. Injects the transaction client as res.locals.tx. Automatically rolls back if the response status is >= 400 or if an unhandled error is thrown.
import {Transactional} from "@mateusseiboth/ts-decorators";
router.post("/orders", Transactional(prisma), async (req, res) => {
// res.locals.tx is the Prisma transaction client
const controller = new OrderController(res.locals.tx, req.body);
await controller.create(req, res);
// commits automatically if status < 400
});Wraps create, update, and deleteById with SQL SAVEPOINTs, allowing partial rollback within an already-open transaction (this.DAO.tx).
import { TransactionalClass, setTransactionalCompanyFn } from "@mateusseiboth/ts-decorators";
@TransactionalClass
class OrderController { ... }
// Optional: run a function at the start of every transaction (e.g. for row-level security)
setTransactionalCompanyFn(async (tx, companyId) => {
await tx.$executeRawUnsafe(`SET LOCAL "app.company_id" = ${companyId}`);
});Zod-powered constructor parameter validation.
⚠️ @Validateis a parameter decorator and is only supported in legacy mode (experimentalDecorators: true). TC39 Stage 3 does not support parameter decorators.
import {Validate, WithValidation} from "@mateusseiboth/ts-decorators";
import {z} from "zod";
@WithValidation // intercepts the constructor call and runs validation
class CreateOrderController {
constructor(
@Validate(z.string().uuid()) orderId: string,
@Validate(z.number().positive()) total: number,
@Validate(z.array(z.string()).min(1)) items: string[],
) {
// only reached if all schemas pass; throws ZodError otherwise
}
}@WithValidation (class decorator) wraps the constructor: before super() is invoked, it calls schema.parse(args[index]) for each parameter decorated with @Validate. A ZodError is thrown immediately on the first failing validation.
Merges res.locals.where, res.locals.orderBy, and res.locals.paginate into a single object ready to spread into any Prisma findMany call.
import {makePrismaOptions} from "@mateusseiboth/ts-decorators";
// Typically used after getWhere + getOrderBy + getPaginate middlewares have run
const options = makePrismaOptions(res);
// → { where: { AND: [...] }, orderBy: [...], skip: 20, take: 10 }
const records = await prisma.street.findMany(options);ℹ️
wherealways hasAND: []even when empty, so you can safely push extra conditions into it without null-checking.
Removes a specific field key from inside AND/OR conditions in a Prisma where object. Useful when you need to programmatically strip a filter after it has been built.
import {removeFromWhere} from "@mateusseiboth/ts-decorators";
const options = makePrismaOptions(res);
// options.where = { AND: [{ OR: [{ enabled: true }] }] }
removeFromWhere(options, "enabled");
// options.where = { AND: [{ OR: [] }] }Two-step pagination helper: first fetches matching IDs, then fetches full rows (with includes) for those IDs. Returns a normalised paginated response.
import {executePrismaQuery} from "@mateusseiboth/ts-decorators";
const result = await executePrismaQuery(prisma.product, options);
// → {
// data: {
// data: Product[],
// paginacao: { totalPage: 5, page: 2, total: 48 }
// }
// }Returns a flat Record<fieldPath, typeName> for all @Field()-decorated properties of a model, including nested models (prefixed with dot-notation).
⚠️ Required dependencies:
- The instance's class MUST have
@InitFields— without it,getFieldTypesreturns{}andcollectFieldTypesreturns an empty object- Only fields with
@Field()are included — other properties are invisible- For nested fields, the nested model's class MUST also have
@InitFields+@Field()on its properties, otherwise the nested branch returns nothing- For nested fields, the relation column MUST have
@NestedModel(RelatedModel)in addition to@Field()
import {collectFieldTypes} from "@mateusseiboth/ts-decorators";
// StreetModel has @InitFields, @Field on id/name, and @Field + @NestedModel on district
// DistrictModel has @InitFields and @Field on id/name
collectFieldTypes(new StreetModel());
// → {
// "id": "string",
// "name": "string",
// "district": "any", ← the relation field itself
// "district.id": "string",
// "district.name": "string",
// }
// ❌ Without @InitFields on StreetModel:
collectFieldTypes(new StreetModel()); // → {}
// ❌ Without @InitFields on DistrictModel:
collectFieldTypes(new StreetModel());
// → { "id": "string", "name": "string", "district": "any" }
// nested district fields are missing| Function | Description |
|---|---|
jwtDecode(token) |
Decodes (no signature verification) a JWT payload from a base64 string |
filterObjectByModel(obj, Model) |
Strips keys from obj that are not registered @Field() properties of Model |
extractAndRemoveByKey(obj, key) |
Recursively finds the first occurrence of key, removes it and returns { value, obj } |
convertBigIntValues(data) |
Recursively converts BigInt and Date values to strings (safe for JSON.stringify) |
addCompanyIdToTransaction(tx, id) |
Executes SET LOCAL "my.company_id" = <id> for row-level security in multi-tenant setups |
bun testContributions are welcome. Please follow the guidelines below to keep things consistent.
bun installbun testAll changes must pass the existing test suite. New features must include tests.
- One concern per decorator/function. Keep decorators focused on a single responsibility.
- Support both legacy and TC39 Stage 3 decorator syntax. Use the
isDecoratorContexthelper fromsrc/decorators/_proxy.tsto branch between the two. - Emit a
warnLegacy("DecoratorName")warning when a decorator is used in legacy mode so users are aware. - Do not add runtime dependencies. All caching, rate limiting and audit logic is intentionally dependency-free.
- Tests use
bun test. Place test files intests/with the.test.tssuffix. Reuse the mock helpers intests/_helpers.tsand the model fixtures intests/_models.ts. - Do not break the metadata WeakMap contract.
@InitFieldsis the initialiser for theCLASS_FIELDSWeakMap — never read fromgetFieldTypesin a decorator that runs before@InitFields.
- Create
src/decorators/myDecorator.ts - Implement both legacy and TC39 branches using
isDecoratorContext - Export it from
index.ts - Add tests in
tests/myDecorator.test.ts - Document it in this README under the Decorators section