defineResource
The one config object that describes a whole REST resource — DTOs, pagination, filters, sort, search, soft delete, scoping, hooks, and route toggles.
#The shape of a resource definition
defineResource() takes one object and returns a definition that the ORM helpers and the generated classes read from. Nothing in this object is executed immediately — it's a description, not an action.
import { defineResource, defineFilters, filter } from '@void-snippets/nestjs';
import { Contact } from './contact.entity';
export const contactsResource = defineResource({
entity: Contact,
name: 'contacts',
pagination: {
mode: 'both',
defaultLimit: 20,
maxLimit: 100,
},
query: {
filters: defineFilters<Contact>({
name: filter.like(),
status: filter.exact(),
age: filter.range(),
}),
sort: ['id', 'name', 'age'],
search: ['name', 'email'],
},
softDelete: 'deletedAt',
});Every field below is optional except name. The sections are grouped by what they're for, not by how important they are — skip straight to the one you need.
#Identity — entity, name, id
defineResource({
entity: Contact, // ← the source of ALL typing below
name: 'contacts', // ← REST base path: /contacts
id: 'id', // ← primary key field. "id" for TypeORM, "_id" for Mongoose (both are the default already)
});entity is the one field that makes everything else type-checked. Pass your TypeORM entity class or your Mongoose document type here, and every other option — filter field names, sort fields, DTO shapes — is checked against it. Get a field name wrong and TypeScript refuses to compile, instead of the mistake surfacing as a confusing runtime bug.
defineFilters<Contact>({ emial: filter.like() });
// ^^^^^ compile error — did you mean "email"?#Body shapes — dto
By default, a POST/PATCH body is accepted exactly as sent, with no validation. Attach DTO classes (decorated with class-validator) and the generated controller validates the body for you automatically — no global ValidationPipe needed:
import { IsEmail, IsInt, IsOptional, IsString } from 'class-validator';
import type { VSCreateInput } from '@void-snippets/nestjs';
export class CreateContactDto implements VSCreateInput<Contact> {
@IsString() name!: string;
@IsEmail() email!: string;
@IsInt() age!: number;
}
export class UpdateContactDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsEmail() email?: string;
}defineResource({
entity: Contact,
name: 'contacts',
dto: { create: CreateContactDto, update: UpdateContactDto },
});Now POST /contacts with a bad email returns 400 automatically, and any field not on the DTO is silently stripped from the body before it reaches your database. The DTO classes also become the real TypeScript types passed to your hooks — beforeCreate(dto) receives a fully-typed CreateContactDto, no manual generics required.
#Pagination — offset, cursor, or both
pagination: {
mode: 'both', // "offset" | "cursor" | "both"
defaultLimit: 20,
maxLimit: 100, // hard ceiling on ?limit=
cursorField: 'id', // tie-breaker for cursor pagination — defaults to the id field
}| Mode | The request looks like | The response looks like |
|---|---|---|
"offset" (default) | ?page=2&limit=20 | { items, page, limit, totalPages, totalDocuments } |
"cursor" | ?cursor=eyJ…&limit=20 | { items, limit, hasNextPage, nextCursor, prevCursor } |
"both" | either of the above | shape matches whichever the request used |
When to use which: offset pagination (page numbers) is what most admin tables want — "go to page 12." Cursor pagination is what infinite-scroll feeds want, and it stays fast no matter how deep you scroll — offset pagination gets slower the deeper you page, because the database has to walk past every skipped row to get there. Cursor pagination doesn't have that problem; it jumps straight to where it left off.
You don't need to understand how the cursor is built to use it — just know that it's a signed, opaque string. Set cursorSecret (in forRoot() or per-resource) so cursors can't be tampered with; without it, the package logs a one-time warning in development.
#Filtering, sorting, search, and field selection — query
These four are how the frontend asks for a specific slice of data through the URL's query string.
Filtering — declare which fields can be filtered on, and what comparisons are allowed for each:
query: {
filters: defineFilters<Contact>({
name: filter.like(), // ?name[like]=Jo
status: filter.exact(), // ?status=active or ?status[in]=active,pending
age: filter.range(), // ?age[gte]=18&age[lte]=65
}),
}| Builder | Allowed comparisons | Coerces the string param to |
|---|---|---|
filter.exact() | equals, not-equals, in-list, not-in-list | string |
filter.like() | partial match, contains, equals | string |
filter.range() | greater/less than (or equal), between | number |
filter.dateRange() | greater/less than (or equal), between | Date |
filter.bool() | equals, not-equals | boolean |
filter.nullable() | is-null, equals, not-equals | string |
A field that isn't listed here is silently ignored if a client tries to filter on it — never trusted, never crashes. A field that is listed but used with a comparison it doesn't allow (e.g. ?name[gte]=x when name only allows like) returns a clean 400 with a message telling the caller what's actually allowed.
Sorting:
query: {
sort: ['id', 'name', 'age'],
// or, with a default when the request sends none:
sort: { allowedFields: ['id', 'name', 'age'], default: '-id' },
}?sort=name sorts ascending, ?sort=-name sorts descending (the leading -), ?sort=-priority,name sorts by multiple fields. A sort request on a field not in the list is a 400.
Search — one query param that matches across several fields at once:
query: { search: ['name', 'email'] }?q=engineer case-insensitively matches "engineer" appearing in either name or email.
Field selection — let clients ask for only the columns they need, for a smaller response:
query: {
select: { allowedFields: ['id', 'name', 'email'], default: ['id', 'name'] },
}?fields=name,email returns only those two fields (plus the id, which is always included — the frontend needs it to build cache keys and cursors). Asking for a field that's not in allowedFields is a 400, not a silently-ignored request.
#Soft delete — softDelete
defineResource({ softDelete: 'deletedAt' });One line turns "delete" into "undo-able delete":
DELETE /contacts/:idsetsdeletedAtto the current time instead of removing the row.
list/get/countautomatically exclude soft-deleted rows.
?includeDeleted=trueopts back in — useful for an admin "trash" view.
PATCH /contacts/:id/restoreclearsdeletedAtand returns the row.
Without softDelete set, DELETE is permanent and hitting the restore endpoint returns 400.
#Optimistic locking — optimisticLock
defineResource({ optimisticLock: 'version' });Prevents two people from silently overwriting each other's changes. Every entity has a version number; an update only succeeds if the version the client sent still matches what's in the database. If someone else updated the row in between, the second update gets a 409 Conflict instead of silently clobbering the first person's change.
#Tenant scoping — scope
If your app has multiple tenants (organizations, workspaces, accounts) sharing one database, scope makes sure a request never sees another tenant's rows — not even by guessing an id:
defineResource({
entity: Ticket,
name: 'tickets',
scope: {
field: 'orgId',
value: (ctx) => ctx.orgId, // pulled from the request context — see below
},
});That one predicate gets automatically added to every operation — list, get, update, remove, restore. A ticket that belongs to a different organization behaves exactly like a ticket that doesn't exist: GET /tickets/42 on someone else's ticket returns 404, not 403 — so an attacker can't even tell whether id 42 exists at all.
By default, a request with no orgId available is rejected outright (onMissing: 'forbid', the default). Set onMissing: 'skip' if you want unscoped requests to fall through unfiltered — useful for admin/internal traffic.
#Request context — where ctx comes from
Every hook below, and the scope.value() function above, receives a RequestContext object:
interface RequestContext {
user?: { id: string; [key: string]: unknown };
orgId?: string;
traceId: string; // for logging/tracing — always present
request?: unknown; // the raw Nest request, escape hatch for anything else
}It's built automatically for every HTTP request — user comes from req.user (populated by whatever auth guard your app already uses), orgId from an x-org-id header, traceId from x-request-id/x-trace-id or generated if absent. You don't wire this up yourself; it's already there by the time any hook runs.
#Lifecycle hooks — hooks
Hooks are the escape hatch for the one-off business rule that doesn't belong in a generic CRUD layer — enrich a payload before insert, send a notification after create, veto a query. Provide them inline in the config, or by overriding a method on a service subclass (see Adding Custom Logic) — a subclass override always wins if both are set.
defineResource({
entity: Contact,
name: 'contacts',
hooks: {
beforeCreate: (dto, ctx) => ({ ...dto, orgId: ctx.orgId }), // stamp the tenant id
afterCreate: async (entity) => {
await mailer.sendWelcome(entity.email);
},
},
});| Hook | When it runs | What you can do |
|---|---|---|
beforeCreate(dto, ctx) | before an insert | Return a replacement DTO to change what gets saved — inject a field, compute a slug. Return nothing to keep it as-is. |
afterCreate(entity, ctx) | after a successful insert | Side effects — send an email, emit an event. Can't change the response anymore. |
beforeUpdate(id, dto, ctx) | before an update | Same as beforeCreate, for updates. |
afterUpdate(entity, ctx) | after a successful update | Side effects. |
beforeDelete(entity, ctx) | before a delete | Side effects, or throw to block the delete. |
afterDelete(entity, ctx) | after a successful delete | Side effects. |
afterRestore(entity, ctx) | after PATCH /:id/restore | Side effects. |
beforeList(params, ctx) | before list and count | Return a replacement params object to force a filter or cap the limit. Runs on both, so they stay consistent. |
afterList(payload, ctx) | after the page is fetched | Reshape the final response — attach extra metadata. |
beforeGet(id, ctx) | before a single-item fetch | Side effects, or throw. |
afterGet(entity, ctx) | after a single-item fetch | Return a replacement to transform the entity. |
serialize(entity, ctx) | on every entity leaving the API | Strip internal fields, add computed ones. Applies to list items, get, create, update, and restore responses. |
#Route toggles — routes
Every one of the nine generated routes (list, get, count, create, createMany, update, remove, removeMany, restore) is on by default. Turn any of them off — a disabled route isn't hidden, it simply doesn't exist (a request to it 404s):
routes: { readOnly: true } // shorthand: only list, get, and count surviveroutes: {
remove: false, // no DELETE /:id at all
bulk: false, // turns off both POST /bulk and DELETE /bulk at once
restore: false, // no PATCH /:id/restore
}Each route can also carry its own settings instead of a plain true/false — guards, interceptors, a custom path, or a custom HTTP status:
routes: {
remove: { guards: [AdminGuard] }, // only DELETE requires this extra guard
}#Guards — every route, or one route
defineResource({
guards: [JwtAuthGuard], // applied to every generated route
routes: {
remove: { guards: [AdminGuard] }, // stacks on top, for DELETE only
},
});This package doesn't ship its own authentication or permissions system on purpose — bring whatever guards your app already uses, and attach them declaratively here instead of writing @UseGuards() by hand on nine generated methods.