Example: Blazor User Creation Form
This example shows how to use Validation in a Blazor component to validate a user creation form, displaying accumulated field errors inline.
The Form Component (CreateUser.razor)
@page "/users/create"
@using DarkPeak.Functional
@using DarkPeak.Functional.Extensions
<h3>Create User</h3>
<EditForm Model="@_request" OnValidSubmit="HandleSubmit">
<div class="mb-3">
<label class="form-label">Name</label>
<InputText @bind-Value="_request.Name" class="form-control" />
@if (FieldErrors("name") is { } nameErrors)
{
@foreach (var error in nameErrors)
{
<div class="text-danger">@error</div>
}
}
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<InputText @bind-Value="_request.Email" class="form-control" />
@if (FieldErrors("email") is { } emailErrors)
{
@foreach (var error in emailErrors)
{
<div class="text-danger">@error</div>
}
}
</div>
<div class="mb-3">
<label class="form-label">Age</label>
<InputNumber @bind-Value="_request.Age" class="form-control" />
@if (FieldErrors("age") is { } ageErrors)
{
@foreach (var error in ageErrors)
{
<div class="text-danger">@error</div>
}
}
</div>
<button type="submit" class="btn btn-primary" disabled="@_submitting">
@(_submitting ? "Creating..." : "Create User")
</button>
@if (_successMessage is not null)
{
<div class="alert alert-success mt-3">@_successMessage</div>
}
@if (_generalError is not null)
{
<div class="alert alert-danger mt-3">@_generalError</div>
}
</EditForm>
@code {
[Inject] private IUserService UserService { get; set; } = default!;
private CreateUserRequest _request = new("", "", 0);
private Dictionary<string, List<string>> _fieldErrors = new();
private string? _successMessage;
private string? _generalError;
private bool _submitting;
private IEnumerable<string>? FieldErrors(string field) =>
_fieldErrors.TryGetValue(field, out var errors) && errors.Count > 0
? errors
: null;
private async Task HandleSubmit()
{
_fieldErrors.Clear();
_successMessage = null;
_generalError = null;
_submitting = true;
var validation = UserValidation.Validate(_request);
await validation.Match(
valid: async user =>
{
var result = await UserService.CreateAsync(user);
result
.Tap(u => _successMessage = $"User {u.Name} created successfully!")
.TapError(err => _generalError = err.Message);
},
invalid: errors =>
{
foreach (var error in errors)
{
var field = error.Code ?? "general";
if (!_fieldErrors.ContainsKey(field))
_fieldErrors[field] = new List<string>();
_fieldErrors[field].Add(error.Message);
}
return Task.CompletedTask;
});
_submitting = false;
}
}
Validation Logic
Keep validation logic separate from the component — it's reusable and testable:
using DarkPeak.Functional;
using DarkPeak.Functional.Extensions;
public record CreateUserRequest(string Name, string Email, int Age);
public record User(string Name, string Email, int Age, Guid Id);
public static class UserValidation
{
public static Validation<User, ValidationError> Validate(CreateUserRequest request) =>
ValidateName(request.Name)
.ZipWith(
ValidateEmail(request.Email),
ValidateAge(request.Age),
(name, email, age) => new User(name, email, age, Guid.NewGuid()));
private static Validation<string, ValidationError> ValidateName(string name)
{
if (string.IsNullOrWhiteSpace(name))
return Validation.Invalid<string, ValidationError>(
new ValidationError { Message = "Name is required", Code = "name" });
if (name.Length < 2)
return Validation.Invalid<string, ValidationError>(
new ValidationError { Message = "Name must be at least 2 characters", Code = "name" });
if (name.Length > 100)
return Validation.Invalid<string, ValidationError>(
new ValidationError { Message = "Name must be 100 characters or fewer", Code = "name" });
return Validation.Valid<string, ValidationError>(name.Trim());
}
private static Validation<string, ValidationError> ValidateEmail(string email)
{
if (string.IsNullOrWhiteSpace(email))
return Validation.Invalid<string, ValidationError>(
new ValidationError { Message = "Email is required", Code = "email" });
if (!email.Contains('@') || !email.Contains('.'))
return Validation.Invalid<string, ValidationError>(
new ValidationError { Message = "Email must be a valid address", Code = "email" });
return Validation.Valid<string, ValidationError>(email.Trim().ToLower());
}
private static Validation<int, ValidationError> ValidateAge(int age)
{
if (age < 18)
return Validation.Invalid<int, ValidationError>(
new ValidationError { Message = "Must be at least 18 years old", Code = "age" });
if (age > 120)
return Validation.Invalid<int, ValidationError>(
new ValidationError { Message = "Age must be 120 or less", Code = "age" });
return Validation.Valid<int, ValidationError>(age);
}
}
User Service
The service returns a Result, keeping the error handling consistent:
public interface IUserService
{
Task<Result<User, Error>> CreateAsync(User user);
}
public class UserService : IUserService
{
private readonly HttpClient _http;
public UserService(HttpClient http) => _http = http;
public async Task<Result<User, Error>> CreateAsync(User user)
{
try
{
var response = await _http.PostAsJsonAsync("/api/users", user);
return response.IsSuccessStatusCode
? Result.Success<User, Error>(user)
: Result.Failure<User, Error>(
new ExternalServiceError
{
Message = $"Server returned {response.StatusCode}",
ServiceName = "UserAPI"
});
}
catch (HttpRequestException ex)
{
return Result.Failure<User, Error>(
new ExternalServiceError
{
Message = ex.Message,
ServiceName = "UserAPI"
});
}
}
}
How It Works
- User fills in the form and clicks Create User
UserValidation.Validate()runs all three validators viaZipWith— errors are accumulated, not short-circuited- On invalid: the
ValidationError.Code(e.g."name","email","age") maps errors to the correct field in the UI - On valid: the
UserServiceis called, returning aResult.Tapsets the success message;TapErrorsets the error message - The component re-renders with either field errors, a success banner, or a general error
Key Takeaways
- Validation accumulates errors — the user sees all problems at once, not one at a time
ValidationError.Codeserves as a field identifier for mapping errors to form inputs- Validation logic is separate from the component — easy to unit test
- Result handles the service call — network errors, server errors, etc. are handled uniformly with
TapError