Server-Driven UI form schema generator for NestJS.
Decorate your DTO properties β the library generates the JSON schema for your Frontend.
Complex systems have forms that change constantly. Adding a new field or changing a required rule usually means touching both the Backend (DTOs) and the Frontend (React/Remix screens), doubling effort and risking inconsistencies.
Invert control. The Frontend stops hardcoding form rules. The Backend becomes the Single Source of Truth. A single endpoint returns a JSON "schema" of the screen, and the Frontend just renders it.
npm install @dsyves/form-schema reflect-metadata
β οΈ Make surereflect-metadatais imported once at the top of your application entry point (e.g.,main.ts):import 'reflect-metadata';
Also enable these flags in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false
}
}// app.module.ts
import { SchemaModule } from '@dsyves/form-schema';
@Module({
imports: [SchemaModule.forRoot()],
})
export class AppModule {}// product.dto.ts
import { UIString, UINumber, UISelect } from '@dsyves/form-schema';
type AppModes = 'create' | 'update' | 'view' | 'audit';
export class ProductDto {
@UIString<AppModes>({
label: 'Product Code',
editableIn: ['create'], // disabled in update/view/audit
required: true,
})
code: string;
@UINumber<AppModes>({
label: 'Weight (kg)',
required: true,
min: 0,
max: 999,
})
weight: number;
@UISelect<AppModes>({
label: 'Category',
options: [
{ label: 'Electronics', value: 'electronics' },
{ label: 'Furniture', value: 'furniture' },
],
})
category: string;
}// form.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { SchemaGeneratorService } from '@dsyves/form-schema';
import { ProductDto } from './product.dto';
@Controller('forms')
export class FormController {
constructor(private readonly schemaGenerator: SchemaGeneratorService) {}
@Get('product')
getSchema(@Query('mode') mode: string) {
return this.schemaGenerator.generate(ProductDto, {
currentMode: mode ?? 'create',
});
}
}{
"formName": "ProductDto",
"requestedMode": "update",
"fields": [
{
"name": "code",
"type": "string",
"label": "Product Code",
"disabled": true,
"validations": { "required": true }
},
{
"name": "weight",
"type": "number",
"label": "Weight (kg)",
"disabled": false,
"validations": { "required": true, "min": 0, "max": 999 }
},
{
"name": "category",
"type": "select",
"label": "Category",
"disabled": false,
"options": [
{ "label": "Electronics", "value": "electronics" },
{ "label": "Furniture", "value": "furniture" }
]
}
]
}Starting from v0.3, the library automatically reads validation constraints registered by class-validator decorators and injects the corresponding rules into the generated schema β without any extra configuration.
Before v0.3, you had to duplicate validation logic:
// β Before β rules written twice
@UIPassword({
label: 'Password',
required: true,
minLength: 8,
pattern: "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[\\W_]).{8,}$",
})
@IsStrongPassword({ minLength: 8, minLowercase: 1, minUppercase: 1, minNumbers: 1, minSymbols: 1 })
password: string;Now you write the rule once:
// β
After β DRY, single source of truth
@UIPassword({ label: 'Password', required: true })
@IsStrongPassword({ minLength: 8, minLowercase: 1, minUppercase: 1, minNumbers: 1, minSymbols: 1 })
password: string;class-validator is an optional peer dependency. Install it only if your project uses it:
npm install class-validatorNo other configuration is needed. The library detects class-validator automatically at runtime via a safe dynamic require(). If it is not installed, the schema generator works exactly as before.
Values explicitly set on the UI decorator always take precedence over inferred values. This lets you override any inferred rule when needed:
@UIPassword({
label: 'Password',
minLength: 16, // overrides inferred 8 from @IsStrongPassword
pattern: '^MyCustomRegex.{16,}$', // overrides the auto-generated pattern
})
@IsStrongPassword({ minLength: 8 })
password: string;class-validator decorator |
Inferred validations field |
Notes |
|---|---|---|
@IsNotEmpty() |
required: true |
β |
@IsDefined() |
required: true |
β |
@IsOptional() |
required: false |
Always wins; clears any required: true from other decorators |
@MinLength(n) |
minLength: n |
β |
@MaxLength(n) |
maxLength: n |
β |
@Length(min, max) |
minLength, maxLength |
β |
@Min(n) |
min: n |
β |
@Max(n) |
max: n |
β |
@IsPositive() |
min: 1 |
HTML min is inclusive |
@IsNegative() |
max: -1 |
β |
@IsLatitude() |
min: -90, max: 90 |
β |
@IsLongitude() |
min: -180, max: 180 |
β |
@IsInt() |
step: 1 |
Restricts <input type="number"> to integers |
@ArrayMinSize(n) |
minLength: n |
β |
@ArrayMaxSize(n) |
maxLength: n |
β |
@ArrayNotEmpty() |
required: true |
β |
@Matches(/regex/) |
pattern from RegExp.source |
Always overwrites inferred patterns |
@Contains("seed") |
pattern: ^.*seed.*$ |
Seed is regex-escaped |
@IsIn(["a","b"]) |
pattern: ^(a|b)$ |
Values are regex-escaped |
@IsEmail() |
pattern (RFC 5321) |
Can be overridden via @UIEmail({ pattern }) |
@IsUrl() |
pattern (http/https/ftp) |
β |
@IsUUID() / @IsUUID("4") |
pattern by version |
Supports v3, v4, v5, or any |
@IsIP() / @IsIP("4") / @IsIP("6") |
pattern by version |
β |
@IsStrongPassword(opts?) |
minLength + pattern with look-aheads |
Pattern is derived from the options you pass |
@IsAlpha() |
pattern: ^[a-zA-Z]+$ |
β |
@IsAlphanumeric() |
pattern: ^[a-zA-Z0-9]+$ |
β |
@IsNumberString() |
pattern (int or decimal) |
β |
@IsDecimal() |
pattern (decimal only) |
β |
@IsLowercase() |
pattern |
β |
@IsUppercase() |
pattern |
β |
@IsHexadecimal() |
pattern |
β |
@IsHexColor() |
pattern (#rgb / #rrggbb) |
β |
@IsOctal() |
pattern |
β |
@IsBase64() |
pattern |
β |
@IsMongoId() |
pattern (24-char hex) |
β |
@IsJWT() |
pattern (3 base64url segments) |
β |
@IsDataURI() |
pattern |
β |
@IsFQDN() |
pattern (domain name) |
β |
@IsISO8601() |
pattern (date/datetime) |
β |
@IsRgbColor() |
pattern (rgb() / rgba()) |
β |
@IsHSL() |
pattern (hsl() / hsla()) |
β |
@IsAscii() |
pattern (printable ASCII) |
β |
@IsIBAN() |
pattern (rough IBAN format) |
β |
@IsPhoneNumber() |
pattern (E.164) |
Generic; not locale-specific |
@IsPostalCode() |
pattern (4β10 digits) |
Generic; not locale-specific |
@IsMimeType() |
pattern |
β |
@IsHash("sha256") |
pattern by algorithm |
Supports md5, sha1, sha256, sha512, and more |
@IsCreditCard() |
pattern (format only) |
Luhn check-digit stays on the backend |
@IsISBN() / @IsISBN(10) / @IsISBN(13) |
pattern by version |
Format only; check-digit stays on the backend |
class-validator decorator |
Reason |
|---|---|
@IsString() |
Type hint only β all form fields are strings by nature |
@IsBoolean() |
Type hint β handled by @UICheckbox |
@IsNumber() |
Type hint β @Min/@Max cover numeric validation |
@IsDate() |
Type hint β handled by @UIDate |
@IsJson() |
JSON validation requires runtime parsing; no useful regex |
@NotContains() |
Negative containment has no HTML attribute equivalent |
@IsNotIn() |
Negative set exclusion has no HTML attribute equivalent |
@IsEmpty() |
Antonym of required β ambiguous in form context |
@IsPassportNumber() |
Hundreds of country-specific formats; too risky to generalize |
| Decorator | HTML Element | Extra Options |
|---|---|---|
@UIString() |
<input type="text"> |
β |
@UINumber() |
<input type="number"> |
β |
@UIEmail() |
<input type="email"> |
β |
@UIPassword() |
<input type="password"> |
β |
@UIDate() |
<input type="date"> |
withTime: true β datetime-local |
@UICheckbox() |
<input type="checkbox"> |
β |
@UIRadio() |
<input type="radio"> |
options: UISelectOption[] (required) |
@UISelect() |
<select> |
options: UISelectOption[] (required) |
@UITextarea() |
<textarea> |
β |
@UIFile() |
<input type="file"> |
accept, maxSizeMb, multiple |
interface UIFieldOptions<TMode extends string = 'create' | 'update' | 'view'> {
label: string;
editableIn?: TMode[]; // modes where this field is editable
required?: boolean;
minLength?: number;
maxLength?: number;
min?: number | string;
max?: number | string;
pattern?: string; // Regex string (HTML5 pattern attribute)
placeholder?: string;
}Instead of a closed enum, the library uses Generic Literal Types. This means you can define your own custom modes and the TypeScript compiler will validate them everywhere:
// Your project defines its own modes
type AppModes = 'create' | 'update' | 'view' | 'audit';
export class ShipmentDto {
@UIString<AppModes>({
label: 'Tracking Code',
editableIn: ['create', 'audit'], // β
TypeScript autocomplete!
// editableIn: ['wrong_mode'], // β compile-time error
})
trackingCode: string;
}import { UIEmail, UIPassword, UIString } from '@dsyves/form-schema';
import {
IsEmail, IsNotEmpty, IsStrongPassword,
IsOptional, Length, Matches,
} from 'class-validator';
export class CreateUserDto {
@UIEmail({ label: 'E-mail' })
@IsEmail()
@IsNotEmpty()
// β required:true and pattern inferred automatically
email: string;
@UIPassword({ label: 'Password' })
@IsStrongPassword({ minLength: 8, minLowercase: 1, minUppercase: 1, minNumbers: 1, minSymbols: 1 })
// β minLength:8 and look-ahead pattern inferred automatically
password: string;
@UIString({ label: 'License Plate', placeholder: 'ABC1D23' })
@Matches(/^[A-Z]{3}\d[A-Z\d]\d{2}$/)
@Length(7, 7)
// β pattern and minLength/maxLength inferred automatically
licensePlate: string;
@UIString({ label: 'Nickname' })
@IsOptional()
// β required:false inferred; field is fully optional
nickname?: string;
}Generated JSON for mode=create:
{
"formName": "CreateUserDto",
"requestedMode": "create",
"fields": [
{
"name": "email",
"type": "email",
"label": "E-mail",
"disabled": false,
"validations": {
"required": true,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"
}
},
{
"name": "password",
"type": "password",
"label": "Password",
"disabled": false,
"validations": {
"minLength": 8,
"pattern": "^(?=(.*[a-z]){1,})(?=(.*[A-Z]){1,})(?=(.*\\d){1,})(?=(.*[\\W_]){1,}).{8,}$"
}
},
{
"name": "licensePlate",
"type": "string",
"label": "License Plate",
"disabled": false,
"placeholder": "ABC1D23",
"validations": {
"minLength": 7,
"maxLength": 7,
"pattern": "^[A-Z]{3}\\d[A-Z\\d]\\d{2}$"
}
},
{
"name": "nickname",
"type": "string",
"label": "Nickname",
"disabled": false,
"validations": { "required": false }
}
]
}You can still pass all rules manually via the UI decorator β everything works exactly as before:
export class ShipmentDto {
@UIString<AppModes>({
label: 'License Plate',
required: true,
minLength: 7,
maxLength: 7,
pattern: '^[A-Z]{3}[0-9][A-Z0-9][0-9]{2}$',
placeholder: 'ABC1D23',
editableIn: ['create', 'audit'],
})
licensePlate: string;
@UIFile<AppModes>({
label: 'Invoice',
accept: ['.pdf', 'image/jpeg', 'image/png'],
maxSizeMb: 5,
multiple: false,
editableIn: ['create', 'audit'],
})
invoiceFile: any;
}src/
βββ types.ts # All interfaces & type definitions
βββ decorators.ts # @UIString, @UINumber, @UISelect, etc.
βββ class-validator-bridge.ts # Optional class-validator metadata reader
βββ schema-generator.service.ts # Core logic: reads metadata β UIFormSchema
βββ schema.module.ts # NestJS Dynamic Module (forRoot / forRootAsync)
βββ index.ts # Public API barrel
- New: Optional integration with
class-validator. TheSchemaGeneratorServicenow automatically reads constraints fromclass-validatordecorators (@IsEmail,@IsStrongPassword,@MinLength,@Matches, etc.) and injects the corresponding validation rules into the generated schema. - New:
stepfield added toUIValidationRules(mapped from@IsInt()). - New:
inferValidationsFromClassValidator()andbuildStrongPasswordPattern()exported as public utilities. - Breaking: None β fully backward compatible. If
class-validatoris not installed, behaviour is identical to v0.2.
Created by Yves de SΓ‘ Barbosa.
MIT