@void-snippets/nestjs

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.

typescript
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

typescript
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.

typescript
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:

typescript
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;
}
typescript
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

typescript
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
}
ModeThe request looks likeThe 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 aboveshape 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:

typescript
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
  }),
}
BuilderAllowed comparisonsCoerces the string param to
filter.exact()equals, not-equals, in-list, not-in-liststring
filter.like()partial match, contains, equalsstring
filter.range()greater/less than (or equal), betweennumber
filter.dateRange()greater/less than (or equal), betweenDate
filter.bool()equals, not-equalsboolean
filter.nullable()is-null, equals, not-equalsstring

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:

typescript
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:

typescript
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:

typescript
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

typescript
defineResource({ softDelete: 'deletedAt' });

One line turns "delete" into "undo-able delete":

  • DELETE /contacts/:id sets deletedAt to the current time instead of removing the row.
  • list / get / count automatically exclude soft-deleted rows.
  • ?includeDeleted=true opts back in — useful for an admin "trash" view.
  • PATCH /contacts/:id/restore clears deletedAt and returns the row.

Without softDelete set, DELETE is permanent and hitting the restore endpoint returns 400.

#Optimistic locking — optimisticLock

typescript
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:

typescript
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:

typescript
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.

typescript
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);
    },
  },
});
HookWhen it runsWhat you can do
beforeCreate(dto, ctx)before an insertReturn 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 insertSide effects — send an email, emit an event. Can't change the response anymore.
beforeUpdate(id, dto, ctx)before an updateSame as beforeCreate, for updates.
afterUpdate(entity, ctx)after a successful updateSide effects.
beforeDelete(entity, ctx)before a deleteSide effects, or throw to block the delete.
afterDelete(entity, ctx)after a successful deleteSide effects.
afterRestore(entity, ctx)after PATCH /:id/restoreSide effects.
beforeList(params, ctx)before list and countReturn 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 fetchedReshape the final response — attach extra metadata.
beforeGet(id, ctx)before a single-item fetchSide effects, or throw.
afterGet(entity, ctx)after a single-item fetchReturn a replacement to transform the entity.
serialize(entity, ctx)on every entity leaving the APIStrip 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):

typescript
routes: { readOnly: true } // shorthand: only list, get, and count survive
typescript
routes: {
  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:

typescript
routes: {
  remove: { guards: [AdminGuard] }, // only DELETE requires this extra guard
}

#Guards — every route, or one route

typescript
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.