Skip to main content

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.

Resources