Validating user input is an important part of any application. In a web application, validation can happen on the client and on the server. Because a backend should never trust the client, server-side validation is always required. Client-side validation is still useful because it gives immediate feedback to the user and reduces unnecessary requests.
In this article, we build a small registration form with Angular Signal Forms, Ionic, and Spring Boot. The client validates username, email, and age. The server repeats the same checks with Jakarta Bean Validation and returns field-level errors that the Angular form can display.
Client ¶
Create an Ionic Angular application:
ionic start validation blank --type=angular
For this standalone Angular application, the HTTP client, Ionic providers, and routes are configured in src/main.ts.
const routes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: HomePage },
];
bootstrapApplication(AppComponent, {
providers: [
provideIonicAngular(),
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
provideHttpClient(withXhr(), withInterceptorsFromDi()),
provideRouter(routes, withHashLocation()),
],
}).catch((err) => console.error(err));
The client uses src/environments/environment.ts to store the server URL.
export const environment = {
production: false,
serverURL: 'http://localhost:8080',
};
In src/app/home/home.page.ts you can see the complete code for the registration form. The form state is stored in a signal, and the field tree is created with form(). The schema function contains the validation rules.
public readonly registration = signal<Registration>({
username: '',
email: '',
age: null,
});
public readonly registrationForm = form(this.registration, (path) => {
required(path.username);
minLength(path.username, 2);
maxLength(path.username, 30);
validateHttp(path.username, {
request: ({ value }) => {
const username = value().trim();
return username.length >= 2
? `${environment.serverURL}/checkUsername?value=${encodeURIComponent(username)}`
: undefined;
},
debounce: 250,
onSuccess: (taken: boolean) =>
taken ? { kind: 'usernameTaken', message: 'Username is already taken' } : undefined,
onError: () => ({
kind: 'usernameCheckFailed',
message: 'Could not check the username',
}),
});
required(path.email);
email(path.email);
required(path.age);
min(path.age, this.minAge, {
error: {
kind: 'notOldEnough',
message: `You must be at least ${this.minAge} years old`,
},
});
});
The form is created with form(this.registration, path => { ... }). The registration signal is the source of truth, and registrationForm is a field tree with the same shape. registrationForm.username, registrationForm.email, and registrationForm.age are the fields that we bind to the template.
The schema function contains the validation rules. Angular Signal Forms provides built-in validators such as required, minLength, maxLength, email, and min. Custom validation is just a function returning a validation error. In the age example above, we use min() with a custom error so that the error kind stays close to the name used later by the server: notOldEnough.
The asynchronous username check uses validateHttp(). It returns undefined from request while the username is too short, so no HTTP request is made until the synchronous validators can pass. The debounce option also prevents a request for every keystroke.
Next, open src/app/home/home.page.html.
<form [formRoot]="registrationForm" (submit)="register()">
<ion-list>
<ion-item>
<ion-input
[class.invalid]="showErrors(registrationForm.username)"
[formField]="registrationForm.username"
label="Username"
labelPlacement="stacked"
type="text"
/>
@if (registrationForm.username().pending()) {
<ion-spinner name="dots" slot="end" />
}
</ion-item>
@if (showErrors(registrationForm.username)) {
@if (hasError(registrationForm.username, 'required')) {
<ion-item class="error-message">
<ion-label> Username is required </ion-label>
</ion-item>
}
@if (hasError(registrationForm.username, 'minLength')) {
<ion-item class="error-message">
<ion-label> Username must be at least 2 characters long </ion-label>
</ion-item>
}
@if (hasError(registrationForm.username, 'maxLength')) {
<ion-item class="error-message">
<ion-label> Username cannot be longer than 30 characters </ion-label>
</ion-item>
}
@if (hasError(registrationForm.username, 'usernameTaken')) {
<ion-item class="error-message">
<ion-label> Username is already taken </ion-label>
</ion-item>
}
@if (hasError(registrationForm.username, 'usernameCheckFailed')) {
<ion-item class="error-message">
<ion-label> Could not check the username </ion-label>
</ion-item>
}
}
<ion-item>
<ion-input
[class.invalid]="showErrors(registrationForm.email)"
[formField]="registrationForm.email"
label="Email"
labelPlacement="stacked"
type="email"
/>
</ion-item>
@if (showErrors(registrationForm.email)) {
@if (hasError(registrationForm.email, 'required')) {
<ion-item class="error-message">
<ion-label> Email is required </ion-label>
</ion-item>
}
@if (hasError(registrationForm.email, 'email')) {
<ion-item class="error-message">
<ion-label> Email is not valid </ion-label>
</ion-item>
}
}
<ion-item>
<ion-input
[class.invalid]="showErrors(registrationForm.age)"
[formField]="registrationForm.age"
label="Age"
labelPlacement="stacked"
type="number"
/>
</ion-item>
@if (showErrors(registrationForm.age)) {
@if (hasError(registrationForm.age, 'required')) {
<ion-item class="error-message">
<ion-label> Age is required </ion-label>
</ion-item>
}
@if (hasError(registrationForm.age, 'notOldEnough')) {
<ion-item class="error-message">
<ion-label> You must be at least {{ minAge }} years old </ion-label>
</ion-item>
}
}
</ion-list>
<ion-button [disabled]="!canSubmit()" expand="block" type="submit">
Create an Account
</ion-button>
</form>
The [formRoot] directive connects the form element to the field tree. formRoot bind a field tree to a <form> element. It automatically sets novalidate on the form element to disable browser validation, listens for the submit event, prevents the default behavior, and calls submit() on the field tree if it defines its own submission options.
The [formField] directive connects a field from the signal form to a UI control. Signal Forms works best with native controls and signal-form-aware custom controls, but it can also bind to components that implement ControlValueAccessor, which is useful for Ionic components.
The template reads field state directly. For example, registrationForm.username().pending() is true while the asynchronous username check is running. registrationForm.username().getError('usernameTaken') returns the validation error if that validator failed. These properties are signals, so Angular updates only the parts of the template that depend on the changed state.
Custom validators ¶
The previous example used min() with a custom error for the age field. If the validation logic is more involved, create a helper function.
import { SchemaPath, validate } from '@angular/forms/signals';
function minimumAge(path: SchemaPath<number | null>, minimum: number): void {
validate(path, ({ value }) => {
const age = value();
if (age == null || age >= minimum) {
return undefined;
}
return {
kind: 'notOldEnough',
message: `You must be at least ${minimum} years old`
};
});
}
You can then replace the min(path.age, 18, ...) call with:
minimumAge(path.age, 18);
A validator receives the field context. The current value is available through value(). Returning undefined means that the value is valid. Returning an object adds a validation error to the field.
Asynchronous validation ¶
The username check asks the server whether a username already exists. On the client, validateHttp() performs the HTTP request and maps the response to a validation error.
validateHttp(path.username, {
request: ({ value }) => {
const username = value().trim();
return username.length >= 2
? `${environment.serverURL}/checkUsername?value=${encodeURIComponent(username)}`
: undefined;
},
debounce: 250,
onSuccess: (taken: boolean) =>
taken ? { kind: 'usernameTaken', message: 'Username is already taken' } : undefined,
onError: () => ({
kind: 'usernameCheckFailed',
message: 'Could not check the username',
}),
On the server, create a small Spring Boot controller. This example stores the taken usernames in a Set. A real application would query a database.
@RestController
@CrossOrigin
public class RegistrationController {
private final Set<String> existingUsernames = new HashSet<>();
RegistrationController() {
this.existingUsernames.add("admin");
this.existingUsernames.add("user");
}
@GetMapping("/checkUsername")
public boolean checkUsername(@RequestParam("value") String value) {
return this.existingUsernames.contains(value.toLowerCase());
}
With this in place, entering admin or user displays the username validation message after a short delay.
Server validation ¶
The server still has to validate the complete registration request. In Spring Boot, use Jakarta Bean Validation annotations on the request object.
public class Registration {
@NotBlank
@Size(min = 2, max = 30)
private String username;
@NotBlank
@Email
private String email;
@Min(18)
@NotNull
private Integer age;
In the controller, the @Valid annotation triggers validation. The BindingResult contains the validation errors. The controller returns a map of field names to error kinds.
@PostMapping("/register")
public Map<String, List<String>> register(@Valid @RequestBody Registration registration,
BindingResult result) {
Map<String, List<String>> errors = new HashMap<>();
if (registration.getUsername() != null
&& this.existingUsernames.contains(registration.getUsername().toLowerCase())) {
errors.computeIfAbsent("username", _ -> new ArrayList<>()).add("usernameTaken");
}
for (FieldError fieldError : result.getFieldErrors()) {
errors.computeIfAbsent(fieldError.getField(), _ -> new ArrayList<>())
.add(toClientError(fieldError));
}
if (errors.isEmpty()) {
System.out.println(registration);
}
return errors;
}
private static String toClientError(FieldError fieldError) {
return switch (fieldError.getCode()) {
case "NotBlank", "NotNull" -> "required";
case "Email" -> "email";
case "Min" -> "notOldEnough";
case "Size" -> {
if (fieldError.getRejectedValue() instanceof String value && value.length() < 2) {
yield "minLength";
}
yield "maxLength";
}
default -> "invalid";
};
}
The method returns a map where the key is the field name and the value is a list of validation error kinds.
{
"email": ["email"],
"age": ["notOldEnough"],
"username": ["usernameTaken"]
}
The client register() method uses Signal Forms' submit() helper. The action sends the form value to the server. If the server returns validation errors, the action maps them back to the matching field tree.
public async register(): Promise<void> {
const success = await submit(this.registrationForm, async (formTree) => {
const errors = await firstValueFrom(
this.http.post<ServerValidationErrors>(
`${environment.serverURL}/register`,
formTree().value(),
),
);
const validationErrors = Object.entries(errors).flatMap(
([fieldName, fieldErrors]) =>
fieldErrors?.map((kind) => ({
fieldTree: formTree[fieldName as keyof Registration],
kind,
message: this.messageFor(kind),
})) ?? [],
);
return validationErrors.length > 0 ? validationErrors : undefined;
});
if (success) {
const toast = await this.toastCtrl.create({
message: 'Registration successful',
duration: 3000,
});
await toast.present();
}
}
submit() also tracks the submitting state. That is why the button can be disabled with registrationForm().submitting() while the request is in flight. If the action returns field errors, Signal Forms integrates them into the form state and the same template messages can display them.
Testing the server path ¶
To verify that the server validation really works, temporarily remove the client validators from the schema and submit invalid data. The server should return the same error kinds, and the Angular form should display the messages.
public readonly registrationForm = form(this.registration);
After the test, put the schema back. Client-side validation is still worth having because it gives immediate feedback and reduces unnecessary requests. But the server should always validate the data again, because a client can be bypassed or manipulated.
You can find the source code for this article on GitHub.