Skip to content

YvesDeSa/form-schemas

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

14 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

@dsyves/form-schema

npm version npm downloads License: MIT TypeScript NestJS LinkedIn

Server-Driven UI form schema generator for NestJS.
Decorate your DTO properties β€” the library generates the JSON schema for your Frontend.


The Problem

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.

The Solution (SDUI)

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.


Installation

npm install @dsyves/form-schema reflect-metadata

⚠️ Make sure reflect-metadata is 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
  }
}

Quick Start

1. Register the module

// app.module.ts
import { SchemaModule } from '@dsyves/form-schema';

@Module({
  imports: [SchemaModule.forRoot()],
})
export class AppModule {}

2. Decorate your DTO

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

3. Generate the schema in your Controller

// 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',
    });
  }
}

4. The JSON output (what your React/Remix receives)

{
  "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" }
      ]
    }
  ]
}

class-validator Integration (v0.3+)

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.

Why this matters

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;

Setup

class-validator is an optional peer dependency. Install it only if your project uses it:

npm install class-validator

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

Merge priority: explicit always wins

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;

Decorator mapping reference

βœ… Fully mapped

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

⏭️ No-op (intentionally not mapped)

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

Available UI Decorators

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

Base Options (all UI decorators)

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

Generic Modes (The Key Design Decision)

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

Advanced Example

With class-validator (recommended)

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 }
    }
  ]
}

Without class-validator

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

Architecture

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

Changelog

v0.3.0

  • New: Optional integration with class-validator. The SchemaGeneratorService now automatically reads constraints from class-validator decorators (@IsEmail, @IsStrongPassword, @MinLength, @Matches, etc.) and injects the corresponding validation rules into the generated schema.
  • New: step field added to UIValidationRules (mapped from @IsInt()).
  • New: inferValidationsFromClassValidator() and buildStrongPasswordPattern() exported as public utilities.
  • Breaking: None β€” fully backward compatible. If class-validator is not installed, behaviour is identical to v0.2.

Author

Created by Yves de SΓ‘ Barbosa.

License

MIT

About

πŸ› οΈ Server-Driven UI form schema generator for NestJS.

Topics

Resources

Stars

Watchers

Forks

Contributors