Type safety is not guaranteed in forms
Let’s address the elephant in the room: Angular forms do not provide type safety. This means we cannot depend on TypeScript to catch bugs, typos, or flawed logic for us, which demands a higher level of vigilance when working with forms. While being careful is essential, there are also some concrete strategies we can adopt to simplify our workflow.
- Minimize the use of
FormGroup.getfor accessing nested controls in aFormGroup. Instead, keep references to these nested controls as properties within the component. - Implement the DTO (Data Transfer Object) pattern to map the form’s data model to the structure expected by a backend service.
- Encapsulate complex form controls into custom components that implement the
ControlValueAccessorinterface.
Let’s explore each of these in detail.
Strategy 1: Reduce reliance on FormGroup.get
Examine the following component code:
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ]
})
export class AppComponent {
form = this.formBuilder.group({
firstName: [''],
lastName: [''],
age: [''],
});
constructor(private formBuilder: FormBuilder) {}
}
It’s a straightforward component that holds a simple FormGroup, so far nothing unusual.
Now, let’s look at how it’s used in the template:
<form [formGroup]="form">
<div>
<label for="firstName">First Name</label>
<input formControlName="firstName" id="firstName"/>
<span *ngIf="form.get('firstName').touched && form.get('firstName').hasError('required')" class="errors">
Field is required
</span>
</div>
<div>
<label for="lastName">Last Name</label>
<input formControlName="lastName" id="lastName"/>
<span *ngIf="form.get('lastName').touched && form.get('larstName').hasError('required')" class="errors">
Field is required
</span>
</div>
</form>
This appears to be a standard form setup. However, there is a subtle issue lurking here. Did you catch it?
On line 13, the string ‘larstName’ is used instead of ‘lastName’. This typo will eventually cause problems. The situation is further complicated because no errors appear in the development console until the form’s value is actually changed — which might only happen when QA is testing (and let’s be honest, many developers trust that such a simple form doesn’t need thorough manual testing). Even when an error does surface, it can be quite confusing at first glance:

This message can be cryptic for many developers. Moreover, repeatedly writing “form.get(‘firstName’)” is not only tedious but also increases the likelihood of introducing typos. It also prevents us from taking advantage of IntelliSense autocomplete, forcing us into a cycle of copy-pasting strings. It’s hardly an ideal developer experience.
Here’s a simple way to improve this:
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
})
export class AppComponent {
form = this.formBuilder.group({
firstName: [''],
lastName: [''],
age: [''],
});
controls = {
firstName: this.form.get('firstName'),
lastName: this.form.get('lastName'),
}
constructor(private formBuilder: FormBuilder) {}
}
The approach above is quite straightforward: we’ve saved direct references to our form controls in an object called controls. This allows us to use them directly in the template in a much cleaner way:
<form [formGroup]="form">
<div>
<label for="firstName">First Name</label>
<input formControlName="firstName" id="firstName"/>
<span *ngIf="controls.lastName.touched && controls.firstName.hasError('required')" class="errors">
Field is required
</span>
</div>
<div>
<label for="lastName">First Name</label>
<input formControlName="lastName" id="lastName"/>
<span *ngIf="controls.lastName.touched && controls.lastName.hasError('required')" class="errors">
Field is required
</span>
</div>
</form>
This might seem like a small change, but it has several important implications:
FormControl.getis a method call. Invoking it directly in the template means it executes on every change detection cycle—which happens frequently. By using cached references, we sidestep this performance overhead.- We only need to create references for the controls that are actually accessed in the template.
- These references are also handy for use within the component class code, eliminating the need for repeated lookups.
This solves one problem, but what about type safety? The values from our controls are still treated as any by TypeScript, which isn’t ideal. How can we address this?
Strategy 2: Apply the Data Transfer Object pattern
Consider a scenario where we need to fill out a complex form and send the data to a server using an HTTP request. It might be tempting to just take the FormGroup.value and send it as-is. However, the server’s API is often designed to receive data in a different shape. For instance, it might expect a Date in ISO format instead of a standard JS string, or it might want a concatenated string of user-selected tags rather than an array. We could adjust the FormGroup structure to match the server, but that might not be ergonomic for the template’s data binding. So what is the best approach here?
This is where the concept of a DTO, or data transfer object, becomes invaluable. A DTO is a specialized object responsible for carrying data between different parts of a system—in our case, from the Angular app to the backend. Its original purpose was to reduce the data volume sent over a network, but it also serves to ensure that different subsystems communicate with a consistent data format. Here is how we can put this pattern to work for us: by creating a simple class. This class takes the form’s raw value in its constructor and produces an object that perfectly matches the server’s API contract, translating any differences between what the form holds and what the server expects. It’s crucial that this class remains a plain data carrier, with no additional business logic, getters, or setters. Such additions would not be correctly serialized by JSON.stringify. The class should have a single focus: to produce a proper DTO. Here’s an example:
export class ArticleDTO {
title: string;
tags: string;
date: string;
referenceIds: number[];
constructor(formValue: RawFormValue) {
this.title = formValue.title;
this.tags = formValue.tags.join(',');
this.date = formValue.date.toISOString();
if (formValue.referenceIds && formValue.referenceIds.length > 0) {
this.referenceIds = formValue.referenceIds;
}
}
}
export interface RawFormValue {
title: string;
tags: string[];
date: Date;
referenceIds?: number[];
}
This class is easy to follow—it simply manages the transformation of the form value into a structure suitable for the backend. All the conversion logic lives inside the constructor. If something goes wrong with the mapping, the constructor is the only place in the codebase where that issue could have arisen. Similarly, if there are type issues, the RawFormValue interface is the single point of reference. Here’s how you might use this pattern in practice:
export class AppComponent {
// rest of the component implementation is ommitted for brevity
submit() {
if (this.form.valid) {
const article = new ArticleDTO(this.form.value as RawFormValue);
// now send the article DTO to the backend using one of your services
}
}
}
There are multiple advantages to this approach:
- We offload business logic related to data manipulation from the component, which is a place where it truly does not belong.
- The logic resides in a single, dedicated class, making bug fixes and maintenance more straightforward.
- This approach works seamlessly with different data management strategies, whether you’re using plain JS objects, state management libraries, or NGRX entity normalizations.
Building custom Angular controls
The final point from the list on simplifying forms is creating custom controls. By implementing the ControlValueAccessor interface in a component and providing it via the NG_VALUE_ACCESSOR token, we can create our own Angular Form Control. This enables our <custom-component></custom-component> to directly integrate with FormControl and ngModel in this manner:
<custom-component formControlName="controlName"></custom-component>
This strategy is powerful for abstracting complex or repetitive logic into a reusable component. Whenever you find yourself implementing heavy, custom logic directly on a single control within a FormGroup, it’s worth considering whether that logic might be better encapsulated inside its own dedicated component.
Make sure to use async validators
One of the most common bad practices I’ve witnessed is developers overlooking the built-in async validators and instead relying on:
- Directives
- Pipes
- Custom code inside the parent component
…to perform simple tasks like checking if an email is already registered. The bottom line is: don’t forget about async validators, and reach for them whenever the situation calls for it.
setValue versus patchValue
When it comes to updating the value of a FormControl, Angular offers two methods: setValue and patchValue. They are largely similar, but there’s one critical distinction. If you call setValue on a FormGroup with an object that is missing any of the keys defined in the form’s structure, it throws an error. This provides a degree of type safety in the otherwise dynamic world of Reactive Forms. However, there’s a catch: setValue also throws an error when the object you pass includes a property that the form does not have. So, the following code will lead to an error:
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
})
export class AppComponent {
form = this.formBuilder.group({
firstName: [''],
lastName: [''],
age: [''],
});
ngOnInit() {
// throws an error
this.form.setValue({
firstName: 'Armen',
lastName: 'Vardanyan',
age: 25,
occupation: 'Software developer, writer',
});
}
constructor(private formBuilder: FormBuilder) {}
}
The error message will read “Error: Cannot find form control with name: occupation.” While this can be helpful, it introduces a serious drawback we need to manage. Take this scenario:
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
})
export class AppComponent {
form = this.formBuilder.group({
firstName: [''],
lastName: [''],
age: [''],
});
async ngOnInit() {
const userData = await this.userService.getUserById(/*some id*/);
this.form.setValue(userData);
}
constructor(
private formBuilder: FormBuilder,
private userService: UserService,
) {}
}
In this example, we’re fetching data from a server and setting it as the form’s value, which seems harmless. What’s the problem?
Right now, everything functions correctly since the server response matches the form’s shape exactly. But imagine a situation where this same API endpoint is used elsewhere in the app, perhaps in a component not under your control. In that UI, there’s a need for more information about the user. The API developers would then add an “occupation” field to the response, assuming that adding a new field won’t break anything. And guess what—it will now break our form because the object we’re trying to set with setValue has an extra property. How should we handle this? We have primarily two options:
- Switch to
patchValuewhenever we assign values from an external API. This covers the immediate problem but could mask future issues if the API undergoes a true breaking change and removes fields. Instead of seeing an error or a blank UI, we’d get silent failures without an error, which might be harder to debug. - The more robust solution involves writing an intermediary function or class that maps the server response to a structure that aligns with our form’s signature. This is essentially the reverse of the DTO process. Here’s an example:
interface RawFormValue {
firstName: string;
lastName: string;
age: number;
}
function toRawFormValue<T extends RawFormValue>(serverData: T): RawFormValue {
return {
firstName: serverData.firstName,
lastName: serverData.lastName,
age: serverData.age,
}
}
Notice how we’ve made the typing as strict as possible. This function takes any object that happens to intersect with our form’s field list, extracts the relevant parts, and returns a new object with exactly those fields. This guarantees compatibility. The sole place we’d need to update if the API design changes is right here in this function.
Understanding form events
One notable aspect of ReactiveForms is their event-driven nature. They emit flags for various state changes—like when a control is touched, becomes dirty, or when its value or validity changes. We leverage these events in many forms, with the most common being subscription to FormControl.valueChanges. This Observable gives us a stream of value updates, which can be triggered either by user interaction or programmatically.
Notice that I said programmatically.
This implies that calling FormControl.setValue will also notify any subscribers of the control’s valueChanges stream. In some cases, this could lead to unintended consequences. Picture a directive that attaches to every [formControl], uses dependency injection to get a handle on NgControl, listens to its valueChanges, removes any trailing spaces to input values, and then sets the new value back on the control. Here’s what that might look like:
@Directive({
selector: '[formControl]'
})
export class TrimDirective implements OnInit {
constructor(
private ngControl: NgControl,
) { }
ngOnInit() {
this.ngControl.valueChanges.subscribe(
(value: string) => this.ngControl.control.setValue(value.trim())
);
}
}
This directive has the desired functionality—preventing trailing spaces. But there’s a potential pitfall: every time a user types, it triggers this directive to set a new value. Setting a new value triggers the directive again, which sets a new value, which triggers the directive again… you can see the infinite loop that’s possible here.
Thankfully, this can be easily prevented with an option known as emitEvent, which is true by default. When set to false, it tells the FormControl to change its value but to skip notifying its subscribers about the update.
Here’s the updated directive:
(value: string) => this.ngControl.control.setValue(value.trim(), {emitEvent: false}),
Note the caution here: if you change a value while
emitEvent: falseis set, any subscriber will not be informed of the change.
Wrapping up
Angular forms are extremely powerful and capable of handling very complex logic and interactions. However, they can also be tricky to work with unless you're aware of their quirks. This guide has covered several common issues and solutions, but it is far from exhaustive. Feel free to keep exploring the breadth of Angular forms; there is always something new to discover and master!
