import { Meta } from '@storybook/addon-docs/blocks'; # Parse, don't validate The wire is untrusted. A `boolean`/`string` field coming back from `fetch` is typed `unknown` until something checks it — casting it away with `as` doesn't check anything, it just tells the compiler to stop complaining. This repo's rule: every response crosses the FE⇄BE seam through a hand-written `parse*` function that returns a `Result` (`src/app/shared/kernel/fp.ts`). Once you hold the parsed value, you never re-check it — the type _is_ the proof. ## Two places this shows up **Value objects** (`src/app/registratie/domain/value-objects/`) parse a single user-entered field — `Postcode`, `Uren`, `BigNummer` — from a raw string into a branded type. **Boundary parsers** (`*.adapter.ts` in every `infrastructure/`) parse a whole DTO — or one enum-ish field inside it — from the generated `ApiClient`'s response into the domain shape the rest of the app trusts. ```ts parsePostcode(raw) // Result |> mapErr(toLocalizedMessage) // swap raw msg → UI copy |> map(toDomain) // only runs on success ``` ## The failure mode this closes: the silent `as` cast An `as SomeUnion` cast on a wire value compiles even when the value doesn't match — the tag just gets forwarded as-is, and something far away breaks on an "impossible" case. A validated parse turns that into an explicit `Failure` at the boundary, right where the untrusted data enters. ### Before/after: `big-register.adapter.ts` ```ts // before — the wire's `type` string is trusted outright function toAantekening(n: AantekeningDto): Aantekening { return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '', }; } ``` ```ts // after — an unrecognized type is a Result you can spec, not a silently-wrong tag const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening']; export function parseAantekening(n: AantekeningDto): Result { if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType)) return err(`aantekening: unknown type ${n.type}`); return ok({ type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '', }); } ``` The resource loader throws on `Failure`, which Angular's `resource()` turns into its error state — the same `Failure` a `RemoteData` consumer already renders, no new plumbing. ### Before/after: `brief.adapter.ts` ```ts // before — `dto.scope` is checked, then re-cast anyway if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) return err('passage: bad shape'); // … scope: dto.scope as PassageScope ``` ```ts // after — split the guard so TS narrows `scope` on its own; no cast needed if (dto.scope !== 'global' && dto.scope !== 'beroep') return err(`passage: unknown scope ${dto.scope}`); // … scope: dto.scope // already narrowed to PassageScope ``` Splitting a compound `if` into two single-condition guards is often enough to make the cast disappear entirely — the compiler was already able to prove the narrowing, the `||` was just hiding it. ### Before/after: `intake-policy.adapter.ts` ```ts // before — the resource exposes the raw DTO; consumers reach into it with `?.` policyResource() { return resource({ loader: () => this.client.policy() }); } ``` ```ts // after — a domain-side type + a validated parse; the resource never surfaces raw wire shape export interface IntakePolicy { readonly scholingThreshold: number; } export function parseIntakePolicy(json: unknown): Result { if (typeof json !== 'object' || json === null) return err('intake-policy: not an object'); const dto = json as { scholingThreshold?: unknown }; if (typeof dto.scholingThreshold !== 'number') return err('intake-policy: missing scholingThreshold'); return ok({ scholingThreshold: dto.scholingThreshold }); } ``` ## The sanctioned exception Narrowing `unknown` to `Partial` so you can _start_ checking fields is fine — that's not a trust decision, it's just giving the compiler a shape to probe (`const dto = json as Partial`, see `dashboard-view.adapter.ts`). What's never fine is casting a field to its final domain type without having checked it first. ## Spec every parser like a decision table Each parser gets a spec covering: a valid shape, a missing required field, and — for tagged/enum-ish values — an unknown tag. See `big-register.adapter.spec.ts`, `intake-policy.adapter.spec.ts`, and the scope-rejection case in `brief.adapter.spec.ts`.