Parse, don't validate: branded types for form input

Add smart constructors parsePostcode/parseUren returning Result<string, Brand>.
The constructor is the only way to mint a Postcode/Uren, so a validated value
is a distinct type from a raw string.

change-request-form now emits a ChangeRequest carrying a parsed Postcode, and
its field errors come straight from the parser's Result — no parallel "is it
valid" flag that can drift out of sync with the value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:55:56 +02:00
parent 57b9f3f804
commit ad50b3fa8f
2 changed files with 52 additions and 7 deletions

29
src/app/core/parse.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Brand, Result, ok, err } from './fp';
/**
* "Parse, don't validate." A validated value gets its own branded type, and the
* smart constructor is the ONLY way to mint one. So once you hold a Postcode you
* know it's well-formed — validity is carried in the type, not re-checked
* everywhere or tracked in a parallel error flag.
*/
export type Postcode = Brand<string, 'Postcode'>;
export type Uren = Brand<number, 'Uren'>;
export function parsePostcode(raw: string): Result<string, Postcode> {
const t = raw.trim().toUpperCase();
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
return err('Voer een geldige postcode in, bijv. 1234 AB.');
}
// Normalise to "1234 AB" — the parser also cleans up.
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
}
export function parseUren(raw: string): Result<string, Uren> {
const t = raw.trim();
const n = Number(t);
// Number('') is 0 — guard the empty string explicitly.
if (t === '' || !Number.isInteger(n) || n < 0) {
return err('Vul een geheel aantal in (0 of meer).');
}
return ok(n as Uren);
}

View File

@@ -4,9 +4,19 @@ import { FormFieldComponent } from '../../molecules/form-field/form-field.compon
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
import { ButtonComponent } from '../../atoms/button/button.component';
import { HeadingComponent } from '../../atoms/heading/heading.component';
import { Postcode, parsePostcode } from '../../core/parse';
/** A submitted change request carries a *parsed* postcode (branded Postcode),
not a raw string — downstream code can't receive an unvalidated one. */
export interface ChangeRequest {
street: string;
zip: Postcode;
city: string;
}
/** Organism: change-request (adreswijziging) form. Reuses the same form-field
molecule + text-input/button atoms as the login form. */
molecule + text-input/button atoms as the login form. Field errors come
straight from the parser's Result — no parallel "is it valid" flag to drift. */
@Component({
selector: 'app-change-request-form',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, HeadingComponent],
@@ -16,8 +26,8 @@ import { HeadingComponent } from '../../atoms/heading/heading.component';
<app-form-field label="Straat en huisnummer" fieldId="street" [error]="streetError()">
<app-text-input inputId="street" [(ngModel)]="street" name="street" [invalid]="!!streetError()" />
</app-form-field>
<app-form-field label="Postcode" fieldId="zip">
<app-text-input inputId="zip" [(ngModel)]="zip" name="zip" placeholder="1234 AB" />
<app-form-field label="Postcode" fieldId="zip" [error]="zipError()">
<app-text-input inputId="zip" [(ngModel)]="zip" name="zip" placeholder="1234 AB" [invalid]="!!zipError()" />
</app-form-field>
<app-form-field label="Woonplaats" fieldId="city">
<app-text-input inputId="city" [(ngModel)]="city" name="city" />
@@ -33,11 +43,17 @@ export class ChangeRequestFormComponent {
zip = '';
city = '';
streetError = signal('');
submitted = output<void>();
zipError = signal('');
submitted = output<ChangeRequest>();
onSubmit() {
if (!this.street.trim()) { this.streetError.set('Vul straat en huisnummer in.'); return; }
this.streetError.set('');
this.submitted.emit();
const street = this.street.trim();
this.streetError.set(street ? '' : 'Vul straat en huisnummer in.');
const postcode = parsePostcode(this.zip);
this.zipError.set(postcode.ok ? '' : postcode.error);
if (!street || !postcode.ok) return;
this.submitted.emit({ street, zip: postcode.value, city: this.city.trim() });
}
}