Forms are where input, rules and the API's errors meet. The rules belong to the server (K2 2.3.8): the form checks only what it can tell the person right away, and shows what the API rejects. This lesson builds two forms of the Kladde: create and change a contact, and the public booking with free times. The contact form is built twice, with reactive forms and with Signal Forms, so that you can compare both ways.
State of the checks. The code comes from the throwaway project outside the repository (Node 24.22.3, Angular 22.2.6, TypeScript 6.0.3, Vitest 5.1.0), at the state of 0203 (generated client).
npm run buildandnpm test -- --no-watchran (exit code 0; final state 32 tests in 7 files, 8 of them for the contact form, 3 for the booking). The tests run in jsdom and answer withHttpTestingController; no browser, no running API, no screen reader. Signal Forms carry the label "New" on the documentation's page and no stability note; whether they are stable is open. The names (form,FormField,FormRoot,required,maxLength,submission,onInvalid,errorSummary,focusBoundControl) were read against the pages "Signal Forms", "Models" and "Form submission" and compiled in the project. The asynchronous validator was tested only as a function. Where K2 names fields differently: check K2's state (2.3.8).
Typed reactive forms
A FormControl derives its type from the initial value; a FormControl('') is FormControl<string | null>, because reset() sets it to null. With nonNullable: true, reset() restores the initial value. The NonNullableFormBuilder applies that to all controls;
inject(NonNullableFormBuilder) returns it. form.value is Partial<...> (disabled controls are missing), getRawValue() returns all fields without undefined. That fits the generated type ContactRequest (0203).
src/app/kontakte/kontakt-formular.ts (excerpt):
protected readonly form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(100)]],
email: ['', [Validators.required, Validators.email]],
company: [''],
});
protected speichern(): void {
this.meldung.set('');
if (this.form.invalid) {
this.form.markAllAsTouched();
this.zumErstenFehler();
return;
}
this.sendet.set(true);
this.speichernAufruf().subscribe({ /* next, error: siehe unten */ });
}
maxLength(100) comes from ContactRequest in the document (maxLength on the field name). The form repeats this rule because the person should see it right away; the API stays authoritative. If the limits change in the contract, the form does not
change by itself: that is the limit of generated types (0203), they carry the shape, not the rules.
The form serves to create and, with an input kontakt = input<ContactResponse>(), to change: an effect calls form.patchValue(kontakt), and speichernAufruf() chooses aendern(id, werte) (PUT) or anlegen(werte) (POST).
The page kontakte/neu stands in app.routes.ts before kontakte/:id (0201), otherwise "neu" would be an id.