emitted alongside standard DTOs without manual intervention.
Step 1: Schema Annotations & Visibility Rules
Annotations drive field-level control without modifying generated output. Triple-slash comments above models or fields instruct the pipeline to apply visibility masks, type overrides, or custom validators.
/// @AuditTag("financial_compliance")
model Organization {
id String @id @default(uuid())
legalName String
/// @DtoHidden
internalCode String
/// @DtoReadOnly
registeredAt DateTime @default(now())
invoices Invoice[]
}
model Invoice {
id String @id @default(uuid())
amount Decimal
/// @DtoOverrideType(BigInt)
ledgerRef String
org Organization @relation(fields: [orgId], references: [id])
orgId String
}
The pipeline interprets @DtoHidden to exclude fields from all DTOs, @DtoReadOnly to strip them from creation/update payloads, and @DtoOverrideType to swap the emitted TypeScript type while auto-resolving imports. Custom annotations like @AuditTag are registered in the external configuration and routed to plugin handlers.
Step 2: Generator Configuration
The Prisma generator block defines output paths, formatting rules, and feature flags. It acts as the entry point for the pipeline.
generator apiContracts {
provider = "prisma-generator-nestjs-dto"
output = "../src/generated/api-contracts"
outputType = "class"
outputStructure = "nestjs"
fileNamingStrategy = "kebab"
reExport = "true"
classValidator = "true"
swaggerDocs = "true"
prettier = "true"
emitManifest = "true"
configFile = "../contracts.pipeline.config.ts"
}
Setting emitManifest = "true" triggers the generation of a runtime type map and a structural manifest. The configFile path points to a TypeScript module that registers custom validators, decorators, and sub-generators. This separation keeps the Prisma schema clean while enabling complex configuration without Prisma's block syntax limitations.
Step 3: Custom Plugin Architecture
Sub-generators extend BaseGenerator and integrate into the pipeline. They receive the parsed model graph, resolved annotations, and import-merging utilities. The jiti runtime loader executes TypeScript plugins without a compilation step, enabling rapid iteration.
import { BaseGenerator, type File, type Model, type Field } from '@tommasomeli/prisma-generator-nestjs-dto';
export class ComplianceManifestGenerator extends BaseGenerator {
filePrefix = '';
fileSuffix = '.compliance';
classPrefix = '';
classSuffix = '';
async generate(): Promise<File[]> {
return this.models
.filter((m: Model) => m.annotations?.includes('AuditTag'))
.map((model: Model) => {
const maskedFields = model.fields.filter((f: Field) => !f.annotations?.includes('DtoHidden'));
const compliancePayload = {
model: model.name,
tag: this.extractAnnotationValue(model, 'AuditTag'),
exposedFields: maskedFields.map(f => f.name),
};
return {
path: this.getPath(model),
content: `export const ${model.name}Compliance = ${JSON.stringify(compliancePayload, null, 2)};`,
};
});
}
private extractAnnotationValue(model: Model, tag: string): string {
const match = model.annotations?.find(a => a.startsWith(tag));
return match?.replace(`${tag}(`, '').replace(')', '') ?? 'unknown';
}
}
The plugin filters models by annotation, extracts metadata, and emits structured compliance files. It leverages the base class's getPath utility to maintain consistent directory structure. The beforeAll and afterAll lifecycle hooks allow cross-model aggregation, such as generating a centralized index or validating annotation consistency across the schema.
Step 4: Type-Safe External Configuration
The configFile registers custom validators, decorators, and plugins using the from() helper. This utility validates module paths and named exports at compile time without invoking the import closure at runtime.
import { from, type GeneratorConfigFile } from '@tommasomeli/prisma-generator-nestjs-dto';
export default {
extraAnnotations: ['AuditTag', 'ComplianceMask'],
extraValidators: from(() => import('../src/common/validators'), ['IsFiscalYear', 'IsPositiveDecimal']),
extraDecorators: from(() => import('../src/common/decorators'), ['SanitizeInput', 'MaskPII']),
extraGenerators: from('./plugins/compliance-generator.ts', ['ComplianceManifestGenerator']),
extraScalars: {
Decimal: { ts: 'Decimal', from: 'decimal.js' },
Json: { ts: 'Record<string, unknown>', from: 'src/types/common', apiType: 'Object' },
},
} satisfies GeneratorConfigFile;
When a custom validator shares a name with a built-in (e.g., IsBoolean), the external module takes precedence for that symbol only. This prevents namespace collisions while allowing domain-specific overrides. The pipeline merges imports automatically, ensuring generated files remain self-contained.
Architecture Decisions & Rationale
- Annotation-Driven Pipeline: Triple-slash comments are natively supported by Prisma's parser. Using them avoids custom DSLs and keeps schema files valid. The generator extracts them during DMMF traversal, eliminating post-processing.
jiti for Plugin Execution: TypeScript plugins require runtime execution. jiti transpiles .ts files on-the-fly without a build step, reducing CI complexity and enabling hot-reload during development.
from() Compile-Time Validation: Importing modules at runtime risks missing exports or path errors. The from() helper uses TypeScript's type system to validate paths and named exports during compilation, failing fast before generation runs.
- Lifecycle Hooks (
beforeAll/afterAll): Cross-model operations (index generation, validation consistency checks) require access to the full model graph. Hooks provide deterministic insertion points without modifying the core emitter.
- Manifest Emission: Runtime logic often requires schema awareness (e.g., dynamic select builders, RBAC field lists). Emitting a typed manifest eliminates DMMF parsing at runtime, reducing cold-start latency and memory overhead.
Pitfall Guide
1. Annotation Namespace Collisions
Explanation: Registering custom annotations without prefixing or scoping can conflict with built-in directives or future Prisma updates.
Fix: Prefix custom annotations with a domain identifier (e.g., @OrgAuditTag) and explicitly declare them in extraAnnotations. Validate schema linting in CI to catch unregistered annotations early.
2. Ignoring Lifecycle Hooks for Cross-Model Logic
Explanation: Attempting to generate aggregated files (indexes, manifests) inside generate() causes duplicate writes or incomplete data.
Fix: Use afterAll(files) for cross-model aggregation. The hook receives the complete file list, enabling safe index generation or validation reporting.
Explanation: Plugin paths relative to configFile may fail in monorepos or CI environments where working directories differ.
Fix: Use absolute paths resolved via path.resolve(__dirname, 'plugins/...') or configure jiti's interopDefault and extensions in the config. Test generation in isolated Docker containers to catch path resolution issues.
4. Overriding Built-in Validators Incorrectly
Explanation: Declaring a custom validator with the same name as a built-in without understanding precedence can silently replace validation logic.
Fix: Audit extraValidators against class-validator's export list. If overriding is intentional, document the replacement and add unit tests that verify the new validator's behavior matches expected edge cases.
5. Stale Runtime Manifests in CI/CD
Explanation: Forcing prisma generate without clearing the output directory can leave orphaned manifest files from deleted models.
Fix: Add a pre-generation cleanup step (rimraf src/generated/api-contracts) in your build script. Alternatively, enable reExport = "true" and rely on barrel files to surface missing exports during compilation.
6. Circular Import Chains in Generated Files
Explanation: Bidirectional relations with @DtoOverrideType can trigger circular dependencies when both sides import each other's DTOs.
Fix: Use forward references or type-only imports (import type { ... }) for relation targets. The generator's import merger handles this automatically if outputStructure = "nestjs" is set, but verify with madge or dependency-cruiser in CI.
7. Bypassing the reExport Barrel Strategy
Explanation: Importing directly from nested DTO files (./organization/create-organization.dto) breaks when file naming strategies change or models are renamed.
Fix: Always import from the root barrel (src/generated/api-contracts). Enable reExport = "true" and configure your linter to forbid deep imports. This decouples consumer code from generator internals.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small REST API (< 10 models) | Default generator + classValidator/swaggerDocs | Minimal overhead, fast iteration | Low |
| Enterprise RBAC + Audit Trails | Enable emitManifest + custom afterAll plugin | Centralized field lists, compliance indexing | Medium (plugin dev) |
| Multi-tenant SaaS | @DtoHidden + @ComplianceMask annotations | Tenant-specific field exposure without code duplication | Low |
| GraphQL + REST Hybrid | Dual generator blocks with separate output paths | Isolated contracts, independent validation pipelines | Medium (config complexity) |
| Legacy Schema Migration | @DtoOverrideType + extraScalars | Bridges Prisma types to legacy DTO expectations | Low |
Configuration Template
generator apiContracts {
provider = "prisma-generator-nestjs-dto"
output = "../src/generated/api-contracts"
outputType = "class"
outputStructure = "nestjs"
fileNamingStrategy = "kebab"
reExport = "true"
classValidator = "true"
swaggerDocs = "true"
prettier = "true"
emitManifest = "true"
configFile = "../contracts.pipeline.config.ts"
}
// contracts.pipeline.config.ts
import { from, type GeneratorConfigFile } from '@tommasomeli/prisma-generator-nestjs-dto';
import path from 'path';
export default {
extraAnnotations: ['AuditTag', 'ComplianceMask', 'TenantScoped'],
extraValidators: from(() => import('../src/common/validators'), ['IsFiscalYear', 'IsPositiveDecimal']),
extraDecorators: from(() => import('../src/common/decorators'), ['SanitizeInput', 'MaskPII']),
extraGenerators: from(path.resolve(__dirname, 'plugins/compliance-generator.ts'), ['ComplianceManifestGenerator']),
extraScalars: {
Decimal: { ts: 'Decimal', from: 'decimal.js' },
Json: { ts: 'Record<string, unknown>', from: 'src/types/common', apiType: 'Object' },
},
} satisfies GeneratorConfigFile;
Quick Start Guide
- Install the generator: Run
npm i -D @tommasomeli/prisma-generator-nestjs-dto in your NestJS project root.
- Add the generator block: Copy the
apiContracts generator configuration into schema.prisma and adjust the output path to match your source structure.
- Create the config file: Generate
contracts.pipeline.config.ts at the path specified in configFile. Register any custom validators, decorators, or plugins using the from() helper.
- Run generation: Execute
npx prisma generate. Verify the output directory contains DTOs, compliance manifests, and the runtime manifest.ts. Import from the root barrel in your NestJS modules.