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:
29
src/app/core/parse.ts
Normal file
29
src/app/core/parse.ts
Normal 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);
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user