Skip to content

Validation

The ProxyGenerator automatically extracts validation rules from your backend validators and generates corresponding TypeScript validation code for the frontend. This ensures that validation rules are defined once on the backend and automatically enforced on both client and server.

The system supports two validation approaches:

  • FluentValidation: Class-based validators using the FluentValidation library
  • DataAnnotations: Attribute-based validation using System.ComponentModel.DataAnnotations

The validation extraction provides:

  • Automatic Rule Extraction: Discovers and extracts both FluentValidation rules and DataAnnotations attributes using reflection
  • Type-Safe Generation: Generates type-safe TypeScript validators for commands and queries
  • Custom Message Preservation: Extracts and carries over custom error messages declared as literals; messages declared as a factory are left to the server to resolve
  • Multiple Validation Styles: Support for both FluentValidation class-based and DataAnnotations attribute-based validation
  • Version Independence: Uses reflection-based type checking without hard dependencies on FluentValidation

The ProxyGenerator uses reflection to:

  1. Discover Validators: Find all AbstractValidator<T> implementations and properties with DataAnnotations attributes for command and query types
  2. Extract Rules: Analyze validation rules from both FluentValidation and DataAnnotations without requiring package references
  3. Generate TypeScript: Create validators with the same rules as the backend, and with the messages it can know at build time
  4. Integrate Automatically: Generated validators run before server calls

The following FluentValidation rules are automatically converted to TypeScript:

FluentValidation RuleTypeScript RuleGenerated Code Example
NotEmpty()notEmpty()this.ruleFor(c => c.email).notEmpty()
NotNull()notNull()this.ruleFor(c => c.value).notNull()
EmailAddress()emailAddress()this.ruleFor(c => c.email).emailAddress()
MinimumLength(n)minLength(n)this.ruleFor(c => c.name).minLength(2)
MaximumLength(n)maxLength(n)this.ruleFor(c => c.name).maxLength(50)
Length(min, max)length(min, max)this.ruleFor(c => c.code).length(3, 10)
Matches(pattern)matches(pattern)this.ruleFor(c => c.phone).matches(/^\d+$/)
GreaterThan(n)greaterThan(n)this.ruleFor(c => c.quantity).greaterThan(0)
GreaterThanOrEqualTo(n)greaterThanOrEqual(n)this.ruleFor(c => c.age).greaterThanOrEqual(18)
LessThan(n)lessThan(n)this.ruleFor(c => c.discount).lessThan(100)
LessThanOrEqualTo(n)lessThanOrEqual(n)this.ruleFor(c => c.rating).lessThanOrEqual(5)

Define a validator on the backend using FluentValidation:

public class CreateUserCommand
{
public string Email { get; set; } = string.Empty;
public int Age { get; set; }
public string Name { get; set; } = string.Empty;
}
public class CreateUserCommandValidator : BaseValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.WithMessage("Email address is required")
.EmailAddress();
RuleFor(x => x.Age)
.GreaterThanOrEqualTo(18);
RuleFor(x => x.Name)
.NotEmpty()
.MinimumLength(2)
.MaximumLength(50);
}
}

The ProxyGenerator automatically generates the corresponding TypeScript validator:

export class CreateUserCommandValidator extends CommandValidator<ICreateUserCommand> {
constructor() {
super();
this.ruleFor(c => c.email)
.notEmpty()
.withMessage('Email address is required')
.emailAddress();
this.ruleFor(c => c.age)
.greaterThanOrEqual(18);
this.ruleFor(c => c.name)
.notEmpty()
.minLength(2)
.maxLength(50);
}
}

As an alternative to FluentValidation, you can use System.ComponentModel.DataAnnotations attributes directly on your command and query properties. The ProxyGenerator will automatically extract these attributes and generate equivalent TypeScript validators.

The following DataAnnotations attributes are automatically converted to TypeScript:

DataAnnotations AttributeTypeScript RuleExample
[Required]notEmpty()[Required] public string Name { get; set; }
[EmailAddress]emailAddress()[EmailAddress] public string Email { get; set; }
[MinLength(n)]minLength(n)[MinLength(2)] public string Code { get; set; }
[MaxLength(n)]maxLength(n)[MaxLength(50)] public string Title { get; set; }
[StringLength(max)]maxLength(max)[StringLength(100)] public string Description { get; set; }
[StringLength(max, MinimumLength=min)]length(min, max)[StringLength(50, MinimumLength=3)]
[Range(min, max)]greaterThanOrEqual(min).lessThanOrEqual(max)[Range(0, 150)] public int Age { get; set; }
[RegularExpression(pattern)]matches(pattern)[RegularExpression(@"^\d+$")]
[Url]matches(urlPattern)[Url] public string Website { get; set; }
[Phone]matches(phonePattern)[Phone] public string PhoneNumber { get; set; }

Define validation using attributes on your command or query:

public class RegisterUserCommand
{
[Required(ErrorMessage = "Name is required")]
[StringLength(50, MinimumLength = 2, ErrorMessage = "Name must be between 2 and 50 characters")]
public string Name { get; set; } = string.Empty;
[Required]
[EmailAddress(ErrorMessage = "Valid email address is required")]
public string Email { get; set; } = string.Empty;
[Range(18, 150, ErrorMessage = "Age must be between 18 and 150")]
public int Age { get; set; }
[Url]
public string Website { get; set; } = string.Empty;
[Phone]
public string PhoneNumber { get; set; } = string.Empty;
}

The ProxyGenerator generates a TypeScript validator from the DataAnnotations attributes:

export class RegisterUserCommandValidator extends CommandValidator<IRegisterUserCommand> {
constructor() {
super();
this.ruleFor(c => c.name)
.notEmpty()
.withMessage('Name is required')
.length(2, 50)
.withMessage('Name must be between 2 and 50 characters');
this.ruleFor(c => c.email)
.notEmpty()
.emailAddress()
.withMessage('Valid email address is required');
this.ruleFor(c => c.age)
.greaterThanOrEqual(18)
.lessThanOrEqual(150)
.withMessage('Age must be between 18 and 150');
this.ruleFor(c => c.website)
.matches(/^https?:\/\/.+/);
this.ruleFor(c => c.phoneNumber)
.matches(/^\+?[1-9]\d{1,14}$/);
}
}

Choosing Between FluentValidation and DataAnnotations

Section titled “Choosing Between FluentValidation and DataAnnotations”

Both approaches are fully supported, and you can choose based on your preferences:

FluentValidation:

  • ✅ More expressive and readable for complex validation logic
  • ✅ Better separation of concerns (validation in separate class)
  • ✅ More flexible and powerful rule composition
  • ✅ Easier to unit test validation logic independently

DataAnnotations:

  • ✅ More concise for simple validation rules
  • ✅ Validation rules are co-located with properties
  • ✅ No additional dependencies required (built into .NET)
  • ✅ Familiar to developers from ASP.NET MVC/Web API

You can also mix both approaches in the same application - the ProxyGenerator will extract rules from both sources.

All validation rules have sensible default error messages that are automatically used when no custom message is specified:

// C# without custom message
RuleFor(x => x.Age).GreaterThanOrEqualTo(18);
// Generated TypeScript (with default message)
this.ruleFor(c => c.age).greaterThanOrEqual(18);
// Default message: "'age' must be greater than or equal to 18."

Custom error messages defined using .WithMessage() are automatically extracted and included in the generated validators:

// C# with custom message
RuleFor(x => x.Email)
.NotEmpty()
.WithMessage("Email address is required");
// Generated TypeScript
this.ruleFor(c => c.email)
.notEmpty()
.withMessage('Email address is required');

FluentValidation also accepts a message as a factory — .WithMessage(_ => Messages.EmailRequired) — which is the form a message read from a resource, a tenant setting or any other ambient state takes. The generator does not project those. The rule still crosses to the client; only the message stays behind:

// C# with a deferred message
RuleFor(x => x.Email)
.NotEmpty()
.WithMessage(_ => Messages.EmailRequired);
// Generated TypeScript — the rule mirrors, the message does not
this.ruleFor(c => c.email)
.notEmpty();
// The client rule falls back to its own default: "'email' must not be empty."

A factory is deferred because its value is not known yet. The generator runs on a build machine, in a different process from the browser that will show the message, at a different time and under different ambient state — so any value it obtained by calling the factory would be an answer for the wrong conditions, frozen into an artifact nobody reviews. A delegate is opaque, so the generator cannot tell a factory that returns a constant from one that reads the culture, and it does not guess.

Query parameters can also be validated using the same approach:

public class SearchUsersQuery
{
public string SearchTerm { get; set; } = string.Empty;
public int MinAge { get; set; }
}
public class SearchUsersQueryValidator : BaseValidator<SearchUsersQuery>
{
public SearchUsersQueryValidator()
{
RuleFor(x => x.SearchTerm).MinimumLength(3);
RuleFor(x => x.MinAge).GreaterThanOrEqualTo(0).LessThanOrEqualTo(150);
}
}

Generated TypeScript:

export class SearchUsersQueryValidator extends QueryValidator<SearchUsersQueryParameters> {
constructor() {
super();
this.ruleFor(c => c.searchTerm).minLength(3);
this.ruleFor(c => c.minAge).greaterThanOrEqual(0).lessThanOrEqual(150);
}
}

Observable queries get the same validator as one-shot queries. It runs when you call perform(), and when you subscribe() — a subscription rejected by validation delivers an invalid QueryResult to your callback rather than opening a connection, so a subscriber can tell “these arguments are wrong” apart from “no data yet”.

A ConceptValidator<T> is extracted for every command property and query parameter of that concept’s type, so a rule written once reaches the client everywhere the concept is used:

public record EmailAddress(string Value) : ConceptAs<string>(Value);
public class EmailAddressValidator : ConceptValidator<EmailAddress>
{
public EmailAddressValidator() =>
RuleFor(x => x.Value).EmailAddress().WithMessage("Must be a valid email address");
}
public class RegisterUser
{
public EmailAddress Email { get; set; } = new(string.Empty);
}

A concept is represented in TypeScript by its underlying primitive, so the rule is attached to the property that carries it:

export class RegisterUserValidator extends CommandValidator<IRegisterUser> {
constructor() {
super();
this.ruleFor(c => c.email)
.emailAddress()
.withMessage('Must be a valid email address');
}
}

Concept rules add to a model’s own rules rather than replacing them. If RegisterUser also has a CommandValidator<RegisterUser> with a rule on Email, both apply — which is what the server does, so the client agrees with it.

Only a concept sitting directly on a property or parameter is extracted. The client rule builder resolves a single property name, so it cannot express a rule against a concept nested deeper in the graph. Those rules still run server-side, where the whole object graph is walked.

The ProxyGenerator can only extract validation rules that can be executed client-side. The following are not supported:

  • Custom validators using .Must(): Business logic that requires server-side execution
  • Async validators: Rules that make database or service calls
  • Complex predicates: Conditions that depend on server-side data
  • Cross-property validation: Rules that compare multiple properties (partially supported)

Validators can have constructor dependencies that are used for server-side validation. The ProxyGenerator handles this automatically:

public class AssignPersonnelValidator : CommandValidator<AssignPersonnel>
{
public AssignPersonnelValidator(PersonnelAlreadyAssigned personnelAlreadyAssigned)
{
RuleFor(x => x.Name).Length(1, 100).NotEmpty();
RuleFor(x => x.Age).NotEmpty();
RuleFor(x => x.RoleId).NotNull();
RuleFor(x => x.PersonId).NotNull();
// This rule requires server-side execution and won't be extracted
RuleFor(x => x)
.MustAsync(async (command, ct) => !await personnelAlreadyAssigned(command.MissionId, command.PersonId))
.WithMessage("Personnel is already assigned to this mission.");
}
}

The ProxyGenerator will:

  1. Create the validator instance with null values for dependencies
  2. Extract all simple validation rules (.Length(), .NotEmpty(), .NotNull())
  3. Skip rules that require dependencies (.MustAsync() with dependency usage)
  4. Generate TypeScript with only the client-side compatible rules

This allows you to keep all validation logic in one place while only the client-compatible rules are extracted.

  • Use out-of-the-box FluentValidation rules for client-side validation
  • Use .Must() and custom validators for server-side business rules
  • Keep simple validation rules on the client for better UX
  • Always enforce all validation on the server for security
  1. User fills out a form and submits a command or query
  2. Generated validator runs automatically before the HTTP call
  3. If validation fails, errors are returned immediately without server call
  4. If validation passes, the request proceeds to the server
  5. Server runs the same validation plus any server-only rules
  1. Define Rules Once: Always define validation on the backend and let ProxyGenerator extract them
  2. Use Simple Rules: Keep client-side rules simple and use server-side for complex logic
  3. Custom Messages: Provide user-friendly messages using .WithMessage()
  4. Don’t Duplicate: Never manually write frontend validators - let the generator handle it
  5. Server Authority: Always validate on the server regardless of client validation