Skip to content
~/rifqi_abrory

Pages

Home
About
Stack
Experience
Projects
Writing
Contact
All projects

Projects

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Articles

Angular signals for everyday admin pages
Typed reactive forms in Angular, without the any
Consistent API errors in ASP.NET Core with ProblemDetails
Passing CancellationToken all the way down in ASP.NET Core
Graceful shutdown for Go HTTP services
Idempotency keys: making payment APIs safe to retry
A spam-resistant contact form with Next.js Server Actions
Offline-friendly forms in React Native: drafts and a submit queue
Request validation and error handling in Spring Boot 3
Keyset pagination in SQL Server: when OFFSET gets slow

Actions

Switch to dark mode
Read in Bahasa Indonesia
Copy email address
All articles

Typed reactive forms in Angular, without the any

Strictly typed FormGroups, non-nullable controls, cross-field validation, and mapping form values to an API request.

3 min read

On this page

Forms are where types usually disappear

Back-office applications are full of long forms: customer data, addresses, payment details. Since Angular 14, reactive forms are strictly typed, but many codebases still read values as any through form.value.someField or form.get("someField"). This article shows a pattern that keeps the types from the form definition all the way to the API call.

Define the form once

Use NonNullableFormBuilder so that reset() returns controls to their initial value instead of null:

import { Component, inject } from "@angular/core";
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";

@Component({
  selector: "app-customer-form",
  imports: [ReactiveFormsModule],
  templateUrl: "./customer-form.html",
})
export class CustomerForm {
  private readonly fb = inject(NonNullableFormBuilder);

  readonly form = this.fb.group({
    fullName: ["", [Validators.required, Validators.maxLength(100)]],
    email: ["", [Validators.required, Validators.email]],
    birthDate: ["", Validators.required],
    address: this.fb.group({
      street: ["", Validators.required],
      city: ["", Validators.required],
      postalCode: ["", [Validators.required, Validators.pattern(/^\d{5}$/)]],
    }),
  });
}

TypeScript now infers the whole structure. this.form.controls.address.controls.city is a FormControl<string>, and a typo in a control name is a compile error, not a runtime null.

Read values through getRawValue

form.value skips disabled controls, so its type marks every field as optional. When submitting, getRawValue() is usually what you want: every field, fully typed.

submit() {
  if (this.form.invalid) {
    this.form.markAllAsTouched();
    return;
  }

  const value = this.form.getRawValue();
  //    ^? { fullName: string; email: string; birthDate: string; address: { ... } }
  this.api.createCustomer(toRequest(value)).subscribe();
}

Map the form to the API, explicitly

The form shape and the API contract are rarely identical. A small mapping function keeps that difference in one place and lets the compiler check both sides:

type CustomerFormValue = ReturnType<CustomerForm["form"]["getRawValue"]>;

export type CreateCustomerRequest = {
  name: string;
  email: string;
  birthDate: string; // ISO yyyy-mm-dd
  address: { line1: string; city: string; postalCode: string };
};

function toRequest(value: CustomerFormValue): CreateCustomerRequest {
  return {
    name: value.fullName.trim(),
    email: value.email.trim().toLowerCase(),
    birthDate: value.birthDate,
    address: {
      line1: value.address.street.trim(),
      city: value.address.city.trim(),
      postalCode: value.address.postalCode,
    },
  };
}

When the backend renames a field, the error shows up in toRequest, not in production.

Cross-field validation

Some rules involve more than one control, for example a date range. Put the validator on the group:

import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";

export const dateRange: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
  const from = group.get("from")?.value as string | undefined;
  const to = group.get("to")?.value as string | undefined;

  return from && to && from > to ? { dateRange: true } : null;
};

readonly period = this.fb.group(
  { from: ["", Validators.required], to: ["", Validators.required] },
  { validators: dateRange },
);

ISO date strings (yyyy-mm-dd) compare correctly as plain strings, which keeps the validator free of time-zone surprises.

Show errors consistently

Instead of repeating *ngIf chains for each field, write one small helper and use it everywhere:

showError(control: AbstractControl, error: string): boolean {
  return control.hasError(error) && (control.touched || control.dirty);
}
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" />
@if (showError(form.controls.email, "email")) {
  <p class="error">Enter a valid email address.</p>
}

Pair the message with aria-invalid and aria-describedby on the input so screen readers announce the error too.

Summary

  • Build forms with NonNullableFormBuilder so values are never unexpectedly null.
  • Submit with getRawValue() to get every field with full types.
  • Keep a typed toRequest mapper between the form and the API contract.
  • Put multi-field rules on the group, and show errors with one shared helper.