Angular forms have long been a source of frustration, with many developers wrestling with them since the early days of Angular 2. The initial release offered only template-driven forms, but Reactive forms soon followed as a second option. Both remain part of the framework today, yet the community has largely championed Reactive forms as the recommended path, advising against the template-driven approach. The rationale behind this preference was:
- Reactive forms lend themselves more naturally to unit testing.
- They reduce the risk of direct two-way binding on model objects.
- Reactive APIs such as
valueChangesandstatusChangesexposed observables to work with. - Validation was seen as stronger, though that point remains debatable.
However, Reactive forms had their own set of drawbacks:
- Type safety was a significant pain point, though version 14 introduced typed forms to address this.
- Handling validation messages and errors required a great deal of repetitive code.
- Both
FormGroupandFormControlare mutable, which means components receiving them as@Input()properties cannot reliably useChangeDetectionStrategy.OnPushwithout occasional manualmarkForCheck()calls.
This makes sense when you consider the many ways forms can be composed and validated. The Angular team has delivered a versatile solution that accommodates various patterns. We can streamline form usage, but only if we remain aligned with the existing API.
A brief side note: discussions still surface about choosing between template-driven and Reactive forms, but we won’t dive into that debate here. Both approaches offer their own trade-offs. In this article, we’ll work with typed Reactive forms in Angular.
Building custom wrappers and abstractions
When consulting for companies, complex forms come up repeatedly, and I often introduce custom form components and abstractions. The goal is to cut down on complexity and redundancy while boosting consistency. In this piece, we’ll craft four bespoke items that work in concert to deliver a clean form-handling setup:
<form-wrapper>component: This wraps the native form element and tracks a submitted state.<form-input-wrapper>component: This includes a label and manages validation error messages.[formInput]directive: This serves as a bridge between the<form-input-wrapper>and the input elements.formInputErrorspipe: This renders errors with minimal effort.
I’m not keen on authoring a custom FormBuilderService, nor do I favor third-party libraries like formly that rely on large configuration blocks housing logic. We aim to stay close to Angular’s native capabilities while eliminating redundancy.
The form-wrapper component
This component acts as a wrapper for the native form element. It can carry styling to ensure consistency across the app, but it also owns a submitted$ BehaviorSubject, which lets us display validation errors only after the form has been submitted. In this design, we avoid bothering users with validation feedback before submission, unless a control is already dirty. Why a BehaviorSubject? A simple boolean submitted property wouldn’t suffice because we need a reference type to consume in another class. That BehaviorSubject can be used to expose a submit output. Beyond that, we rely on content projection to insert the form’s actual content, and that’s about all:
Note: For this article, we’ve opted for the standalone component structure, though modules would work too.
@Component({
selector: 'form-wrapper',
imports: [CommonModule, ReactiveFormsModule],
template: `
<form (submit)="onSubmit()" [formGroup]="formGroup">
<ng-content></ng-content>
</form>
`,
standalone: true
})
export class FormWrapperComponent {
@Input() public readonly formGroup: FormGroup;
public readonly submitted$ = new BehaviorSubject(false);
// skip(1): don't emit the initial value
@Output() public readonly submit = this.submitted$.pipe(skip(1));
public onSubmit(): void {
this.submitted$.next(true);
}
}
form-input-wrapper component
The form-input-wrapper component takes care of most of our repetitive code, cutting it down to just three lines per input. Think about all the *ngIf blocks we’d otherwise rewrite for each input. It also ensures the label appears above the input rather than beside it. This component includes a label and hosts an element that implements the ControlValueAccessor interface.
We could consume the component like this:
<form-wrapper [formGroup]="form" (submit)="onSubmit()">
<form-input-wrapper label="First name">
<input type="text" [formControl]="form.controls.firstName"
</form-input-wrapper>
...
</form-wrapper>
The implementation of the form-input-wrapper might look like this:
@Component({
selector: 'form-input-wrapper',
template: `
<label></label>
<ng-content></ng-content>
<!-- Todo: add validation errors later -->
`,
imports: [CommonModule],
standalone: true,
})
export class FormInputWrapperComponent {
@Input() public readonly label: string;
}
As shown, we’re not dealing with validation error messages yet — that comes later. For now, this component renders a label and projects its content.
form-input directive
Inside the <form-input-wrapper> component, we need a reference to the form control because that control carries the validity state we want to display. We could pass the form control in as an input, but that would add repetitive work each time, which defeats the purpose. Instead, we need a way to access that control from within the <form-input-wrapper>. We’ll use @ContentChildren() for this, but the child element could be an form-input, a textarea, or some custom control implementing the ControlValueAccessor interface. That makes it unclear which query to pass to @ContentChildren(). To gain access to the projected element, we can define a [formInput] directive applied to all such elements. Then, we can use @ContentChildren(FormInputDirective) to grab that reference.
The [formInput] directive holds no logic; as noted earlier, it’s simply the glue giving access:
@Directive({
selector: '[formInput]',
standalone: true
})
export class FormInputDirective {
@Input() public readonly formControl: FormControl;
}
We must apply the [formInput] directive to every input element (or any element implementing the ControlValueAccessor interface) to bind it to its <form-input-wrapper> component:
<form-input-wrapper label="First name">
<input
type="text"
formInput
[formControl]="form.controls.user.controls.firstName"
/>
</form-input-wrapper>
With @ContentChildren(FormInputDirective), we can now add a formInputs property to the <form-input-wrapper> component, which we’ll later use to read its validity state.
export class FormInputWrapperComponent {
@Input() public readonly label: string;
@ContentChildren(FormInputDirective, { descendants: true })
public readonly formInputs: QueryList<FormInputDirective>;
}
The structure
A quick recap: we have one <form-wrapper> component holding the submitted state, which wraps <form-input-wrapper> components to minimize redundancy, with a [formInput] directive serving as the glue. In the following code, we show an example with a general form group and an address form group. This HTML stays fixed, so it’s the only markup we’ll need going forward when building forms.
<form-wrapper [formGroup]="form" (submit)="onSubmit()">
<fieldset>
<h3>General</h3>
<form-input-wrapper label="First name">
<input
type="text"
formInput
[formControl]="form.controls.user.controls.firstName"
/>
</form-input-wrapper>
<form-input-wrapper label="Last name">
<input
type="text"
formInput
[formControl]="form.controls.user.controls.lastName"
/>
</form-input-wrapper>
<form-input-wrapper label="Age">
<input
type="number"
formInput
[formControl]="form.controls.user.controls.age"
/>
</form-input-wrapper>
</fieldset>
<fieldset>
<h3>Address</h3>
<form-input-wrapper label="Street">
<input
type="text"
formInput
[formControl]="form.controls.address.controls.street"
/>
</form-input-wrapper>
<form-input-wrapper label="Street number">
<input
type="text"
formInput
[formControl]="form.controls.address.controls.streetNumber"
/>
</form-input-wrapper>
<form-input-wrapper label="City">
<input
type="text"
formInput
[formControl]="form.controls.address.controls.city"
/>
</form-input-wrapper>
<form-input-wrapper label="Zipcode">
<input
type="text"
formInput
[formControl]="form.controls.address.controls.zipCode"
/>
</form-input-wrapper>
</fieldset>
<button type="submit">Submit</button>
</form-wrapper>
Typed forms require types. We could use types, but we’ll go with classes instead so we can leverage decorators for validation later. We’ll create the two classes UserForm and AddressForm, and use the FormBuilder API to assemble our form:
Note: Making these properties readonly encourages an immutable working style
export class UserForm {
public readonly firstName: string;
public readonly lastName: string;
public readonly age: number;
}
export class AddressForm {
public readonly street: string;
public readonly streetNumber: string;
public readonly city: string;
public readonly zipCode: string;
}
@Component({
...
templateUrl: './app.component.html',
imports: [FormWrapperComponent, FormInputWrapperComponent, FormInputDirective, ReactiveFormsModule, FormsModule, CommonModule],
standalone: true,
})
export class AppComponent {
private readonly addressForm = this.fb.group<AddressForm>({
street: '',
streetNumber: '',
city: '',
zipCode: ''
});
private readonly userForm = this.fb.group<UserForm>({
firstName: '',
lastName: '',
age: null
});
public readonly form = this.fb.group({
user: this.userForm,
address: this.addressForm
})
constructor(private fb: FormBuilder){
}
public onSubmit(): void {
console.log(this.form.value);
}
}
Validation
For clear and consistent validation, we turn to class-validator. This library works on both the frontend and backend, and it also supports creating your own validators. Because it provides decorators, we can apply these directly to our form classes.
import { IsNotEmpty, Min, Max } from 'class-validator';
export class UserForm {
@IsNotEmpty()
public readonly firstName: string;
@IsNotEmpty()
public readonly lastName: string;
@Min(0)
@Max(100)
public readonly age: number;
}
export class AddressForm {
@IsNotEmpty()
public readonly street: string;
@IsNotEmpty()
public readonly streetNumber: string;
@IsNotEmpty()
public readonly city: string;
@IsNotEmpty()
public readonly zipCode: string;
}
Our next step is to transform these classes into sets of async validators and attach them to our userForm and addressForm. The reason we use async validators is that class-validator returns promises when performing validation. We'll build a shared utility called addAsyncValidators() that applies these validators to our form groups. The first argument is the form group, and the second is the type of its corresponding form class (the one with the validation decorators).
private readonly addressForm = addAsyncValidators(this.fb.group<AddressForm>({
street: '',
streetNumber: '',
city: '',
zipCode: ''
}), AddressForm);
private readonly userForm = addAsyncValidators(this.fb.group<UserForm>({
firstName: '',
lastName: '',
age: null
}), UserForm);
public readonly form = this.fb.group({
user: this.userForm,
address: this.addressForm
})
There's a deliberate reason behind this form composition. Our form classes will never contain nested properties. Each nested property becomes its own form group and uses the addAsyncValidators() function. This keeps things simple while allowing us to continue using the standard Angular FormBuilder API.
The addAsyncValidators() function iterates over the form and adds the appropriate validators based on the decorators in the class. I won't dive into the nitty-gritty of this implementation; it's not the focus of this article, and this piece is already lengthy. Just understand that you only need to write this once and can place it in a shared library. Feel free to copy it, enhance it, and let me know! In short: we loop through the form object, locate the keys in our class that use decorators, convert those decorators into async validators, and create a new form group with those validators included.
// form-utils.ts
import {
AbstractControl,
AsyncValidatorFn,
FormControl,
FormGroup,
} from '@angular/forms';
import { validate, ValidationError } from 'class-validator';
export function addAsyncValidators<T extends FormGroup>(
form: T,
formType: new () => any
): T {
const groupObj = Object.keys(form.controls).reduce(
(obj: { [key in keyof T]: FormControl }, key: keyof T & string) => {
const validators = [createValidatorFn(key, formType)];
const formControl = new FormControl(form.value[key], {
asyncValidators: validators,
});
return {
...obj,
[key]: formControl,
};
},
{} as { [key in keyof T]: FormControl }
);
return new FormGroup(groupObj as { [key in keyof T]: FormControl }) as T;
}
function createValidatorFn<T>(
key: string,
formType: new () => any
): AsyncValidatorFn {
return (control: AbstractControl) => {
const toValidate = new formType();
toValidate[key] = control.value;
return validate(toValidate).then((validationErrors: ValidationError[]) => {
const err = validationErrors.find(
(v: ValidationError) => v?.property === key
);
return err
? ({
constraints: err.constraints,
contexts: err.contexts,
} as ValidationError)
: null;
});
};
}
Displaying the validation messages
To display the validation messages, we make use of the formInputs property on the <form-input-wrapper> component. The errors we want to present are located in formInputs?.first?.formControl?.errors. The validation errors generated by class-validator follow a specific structure: constraints[keyname] contains the validation message. Since a single form input can have multiple validation messages, let's create a pipe to iterate over this data and return a list of them. We'll call it the formInputErrors pipe; it checks for the existence of constraints and, if present, provides a neat list of these validation errors:
import { Pipe, PipeTransform } from '@angular/core';
import { ValidationErrors } from '@angular/forms';
@Pipe({
name: 'formInputErrors',
standalone: true
})
export class FormInputErrorsPipe implements PipeTransform {
transform(value: ValidationErrors | null | undefined, args?: any): any {
if(value?.constraints){
return Object.keys(value?.constraints).map(key => {
return value?.constraints[key]
});
}
return null;
}
}
To use this pipe, we add an *ngFor statement inside the <form-input-wrapper> component. Since we're working with standalone components, remember to include the FormInputErrorsPipe in the imports of that component:
import { CommonModule } from '@angular/common';
import { Component, ContentChildren, Input, QueryList } from '@angular/core';
import { FormInputErrorsPipe } from '../form-input-errors.pipe';
import { FormInputDirective } from '../form-input.directive';
@Component({
selector: 'form-input-wrapper',
template: `
<label></label> <ng-content></ng-content>
<ul>
<li *ngFor="let child of formInputs?.first?.formControl?.errors|formInputErrors"></li>
</ul>
`,
imports: [CommonModule, FormInputDirective, FormInputErrorsPipe],
standalone: true,
})
export class FormInputWrapperComponent {
@Input() public readonly label: string;
// search for elements of type FormInputDirective in the <ng-content>
@ContentChildren(FormInputDirective, { descendants: true })
public readonly formInputs: QueryList<FormInputDirective>;
}
Adding a validation class on the formInput directive
Sometimes, you might want to apply a red border or another specific style to invalid fields. Let's add a CSS class called form-input--invalid when the form input contains errors. We can accomplish this with a @HostBinding(), injecting the FormWrapperComponent, and using the form control passed in through an @Input() property.
export class FormInputDirective {
private readonly submitted$ = inject(FormWrapperComponent).submitted$; // a value type wouldn't be sufficient here
@Input() public readonly formControl: FormControl;
@HostBinding('class.form-input--invalid')
public get invalid(): boolean {
return (
this.formControl?.invalid && (this.formControl?.touched || this.submitted$.value)
)
}
}
This will apply the form-input--invalid class whenever the control is invalid and has been touched, or when the form has been submitted.
Conditionally showing the validations with dependency injection
Now that we have the validations, we only want to reveal them when the form is submitted or the control has been touched. To do this, we can inject the FormWrapperComponent into the <form-input-wrapper> component and add an *ngIf statement on the <ul> element. This condition checks if the form is submitted or if the form control is dirty.
@Component({
selector: 'form-input-wrapper',
imports: [
CommonModule,
FormInputDirective,
FormInputErrorsPipe,
],
standalone: true,
template: `
<label></label> <ng-content></ng-content>
<ul *ngIf="(submitted$|async)||
(formInputs?.first?.formControl.touched && formInputs?.first?.formControl?.errors)">
<li *ngFor="let child of formInputs?.first?.formControl?.errors|formInputErrors"></li>
</ul>
`,
})
export class FormInputWrapperComponent {
@Input() public readonly label: string;
@ContentChildren(FormInputDirective, { descendants: true })
public readonly formInputs: QueryList<FormInputDirective>;
public readonly submitted$ = inject(FormWrapperComponent).submitted$;
}
Custom validator
Creating a custom validator for class-validator is straightforward. Let's build a noSpecialChars validator that will flag an error if the user enters a special character in any of the name fields. The way we use this validator is as follows:
We'll apply our decorator like this:
function noSpecialChars(value: string): boolean {
const regex = /[\!\@\#\$\%\^\&\*\)\(\+\=\.\<\>\{\}\[\]\:\;\'\"\|\~\`\_]/g;
return !regex.test(value);
}
export class UserForm {
@IsNotEmpty()
@CustomValidator(noSpecialChars, {message: 'First name can not contain special chars'})
public readonly firstName: string;
@IsNotEmpty()
@CustomValidator(noSpecialChars, {message: 'Last name can not contain special chars'})
public readonly lastName: string;
@Min(0)
@Max(100)
public readonly age: number;
}
We've created a @CustomValidator() because it's convenient to pass in a validation function. The implementation of the @CustomValidator() is shown in the code sample below. For more in-depth information, I recommend checking the official documentation
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
export function CustomValidator(validatorFn: Function, options? : ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'custom',
target: object.constructor,
propertyName: propertyName,
options: options,
constraints: [],
validator: {
validate(value: any, args: ValidationArguments) {
return validatorFn(value);
}
}
});
};
}
Conclusion
That's a wrap! We've built some custom logic to reduce the amount of form code we'll need to write in the future.
- We'll have to write less HTML going forward.
- We have a standardized method for placing inputs and labels.
- We have a consistent approach to showing validation errors and deciding when to show them (on submit or when dirty).
- We have clean form classes that we decorate with validators.
- We can create custom decorators and pass them to the
CustomValidator()decorator.
You can check out the Stackblitz example here:
A big thanks to the reviewers!!

•