RxJS & Forms Improvements (Angular 15+)
Overview
Angular 15+ brings significant improvements to reactive forms with strict typing and enhanced RxJS integration.
Typed Reactive Forms
Before Angular 14
Forms were untyped and error-prone:
// Untyped - no compile-time safety
this.userForm = this.fb.group({
name: [""],
email: [""],
age: [0],
});
// TypeScript can't catch this error
this.userForm.get("namee")?.value; // Typo not caught!
Angular 15+ Typed Forms
interface UserForm {
name: FormControl<string>;
email: FormControl<string>;
age: FormControl<number>;
}
// Fully typed
userForm = this.fb.group<UserForm>({
name: this.fb.control("", { nonNullable: true }),
email: this.fb.control("", { nonNullable: true }),
age: this.fb.control(0, { nonNullable: true }),
});
// TypeScript catches errors
this.userForm.controls.name.value; // string
this.userForm.controls.namee; // Error: Property 'namee' does not exist
NonNullable Forms
Handle Null Values Explicitly
// Without nonNullable - can be null
const control = new FormControl<string>("");
const value: string | null = control.value;
// With nonNullable - never null
const control = new FormControl<string>("", { nonNullable: true });
const value: string = control.value; // Always string
Typed FormGroup
interface ProfileForm {
firstName: FormControl<string>;
lastName: FormControl<string>;
contact: FormGroup<{
email: FormControl<string>;
phone: FormControl<string | null>; // Can be null
}>;
}
profileForm = this.fb.group<ProfileForm>({
firstName: this.fb.control("", { nonNullable: true }),
lastName: this.fb.control("", { nonNullable: true }),
contact: this.fb.group({
email: this.fb.control("", { nonNullable: true }),
phone: this.fb.control<string | null>(null),
}),
});
// Full type safety
const email: string = this.profileForm.controls.contact.controls.email.value;
FormArray with Types
interface TaskForm {
tasks: FormArray<FormControl<string>>;
}
taskForm = this.fb.group<TaskForm>({
tasks: this.fb.array<FormControl<string>>([])
});
addTask() {
const task = this.fb.control('', { nonNullable: true });
this.taskForm.controls.tasks.push(task);
}
getTasks(): string[] {
return this.taskForm.controls.tasks.value; // string[]
}
Validators with Types
// Typed custom validator
function minAgeValidator(minAge: number): ValidatorFn {
return (control: AbstractControl<number>): ValidationErrors | null => {
if (control.value < minAge) {
return { minAge: { required: minAge, actual: control.value } };
}
return null;
};
}
// Usage
ageControl = this.fb.control(0, {
nonNullable: true,
validators: [Validators.required, minAgeValidator(18)],
});
RxJS Integration
valueChanges with Type Safety
// Typed value changes
this.userForm.controls.email.valueChanges
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((email) => this.checkEmailAvailability(email))
)
.subscribe((available) => {
// email is typed as string
console.log(`Email ${email} available:`, available);
});
StatusChanges
this.userForm.statusChanges
.pipe(
filter((status) => status === "VALID"),
tap(() => console.log("Form is valid!"))
)
.subscribe();
Advanced Form Patterns
Dynamic Form with Types
interface DynamicFieldConfig {
key: string;
type: "text" | "email" | "number";
label: string;
validators?: ValidatorFn[];
}
class TypedDynamicForm {
private fb = inject(FormBuilder);
createForm(fields: DynamicFieldConfig[]): FormGroup {
const group: { [key: string]: FormControl } = {};
fields.forEach((field) => {
group[field.key] = this.fb.control("", {
nonNullable: true,
validators: field.validators || [],
});
});
return this.fb.group(group);
}
}
Nested Forms
interface AddressForm {
street: FormControl<string>;
city: FormControl<string>;
zipCode: FormControl<string>;
}
interface UserFormWithAddress {
name: FormControl<string>;
addresses: FormArray<FormGroup<AddressForm>>;
}
userForm = this.fb.group<UserFormWithAddress>({
name: this.fb.control('', { nonNullable: true }),
addresses: this.fb.array<FormGroup<AddressForm>>([])
});
addAddress() {
const address = this.fb.group<AddressForm>({
street: this.fb.control('', { nonNullable: true }),
city: this.fb.control('', { nonNullable: true }),
zipCode: this.fb.control('', { nonNullable: true })
});
this.userForm.controls.addresses.push(address);
}
RxJS Operators Improvements
New Operators
import { connect, retry, shareReplay } from "rxjs";
// connect - share source without subscription
const shared$ = source$.pipe(
connect((shared) => merge(shared.pipe(map((x) => x * 2)), shared.pipe(filter((x) => x > 10))))
);
// Enhanced retry with config
data$ = this.http.get("/api/data").pipe(
retry({
count: 3,
delay: 1000,
resetOnSuccess: true,
})
);
Improved Type Inference
// Better type inference in pipe chains
const result$ = of({ id: 1, name: "Test" }).pipe(
map((user) => user.name), // TypeScript knows this is string
filter((name) => name.length > 0), // name is still string
switchMap((name) => this.search(name)) // name is string
);
Form Validation Patterns
Cross-Field Validation
function passwordMatchValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const password = control.get("password");
const confirmPassword = control.get("confirmPassword");
if (!password || !confirmPassword) {
return null;
}
return password.value === confirmPassword.value ? null : { passwordMismatch: true };
};
}
// Usage
registrationForm = this.fb.group(
{
password: ["", [Validators.required, Validators.minLength(8)]],
confirmPassword: ["", Validators.required],
},
{ validators: passwordMatchValidator() }
);
Async Validators
function emailAvailableValidator(service: UserService): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
return service.checkEmailAvailability(control.value).pipe(
map((available) => (available ? null : { emailTaken: true })),
catchError(() => of(null))
);
};
}
// Usage
emailControl = this.fb.control("", {
nonNullable: true,
validators: [Validators.required, Validators.email],
asyncValidators: [emailAvailableValidator(this.userService)],
});
Migration from Untyped Forms
Before (Untyped)
userForm = this.fb.group({
name: [""],
email: [""],
});
// Unsafe access
const name: any = this.userForm.get("name")?.value;
After (Typed)
interface UserForm {
name: FormControl<string>;
email: FormControl<string>;
}
userForm = this.fb.group<UserForm>({
name: this.fb.control("", { nonNullable: true }),
email: this.fb.control("", { nonNullable: true }),
});
// Type-safe access
const name: string = this.userForm.controls.name.value;
Best Practices
1. Always Use Typed Forms
// ✅ Good
interface MyForm {
field: FormControl<string>;
}
const form = this.fb.group<MyForm>({
/* ... */
});
// ❌ Bad
const form = this.fb.group({ field: [""] });
2. Use nonNullable for Required Fields
// ✅ Good
const control = this.fb.control("", { nonNullable: true });
// ❌ Bad (can be null)
const control = this.fb.control("");
3. Leverage RxJS Operators
// ✅ Good
this.searchControl.valueChanges
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((term) => this.search(term))
)
.subscribe();
4. Clean Up Subscriptions
// ✅ Good
private destroy$ = new Subject<void>();
ngOnInit() {
this.form.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
Interview Questions
Q: What are typed reactive forms in Angular 15+? A: Forms with full TypeScript type safety, preventing runtime errors and providing better IDE support.
Q: What is the nonNullable option? A: Ensures form control values are never null, simplifying type handling for required fields.
Q: How do typed forms improve developer experience? A: Compile-time type checking, better autocomplete, catch errors before runtime, easier refactoring.
Q: When should you use async validators? A: For server-side validation like checking username availability, email uniqueness, etc.