Files
atomic-design-poc/docs/backlog/WP-06-typed-async.md
Edwin van den Houdt 199cbe1f8c feat(fp): WP-06 — kill $any() in templates (18x)
Make AsyncLoadedDirective generic with a static ngTemplateContextGuard for
AsyncComponent's own internal typing. That can't propagate to consumer
`<ng-template appAsyncLoaded let-p>` sites though -- Angular only infers a
structural directive's type parameter from an input bound on that same
node, not from a sibling input on the parent component -- so the ~9
root-cause consumers (dashboard, registration-detail, aanvraag-detail,
registratie-wizard) instead unwrap the RemoteData Success value via a
typed computed() and narrow it locally with `@if (x(); as p)`. The
remaining union-narrowing casts (registration-summary, showcase concepts
page) are replaced with a stable @let binding and a direct resource read,
respectively. Documented as a deviation in WP-06's backlog file.
2026-07-03 21:27:01 +02:00

4.4 KiB
Raw Permalink Blame History

WP-06 — Generic async template contexts: kill $any() (18×)

Status: done Phase: 1 — FP/DDD core

Why

18 $any() casts in templates defeat strict template checking. Root cause for ~9 of them: AsyncLoadedDirective types its template context as { $implicit: unknown } (src/app/shared/ui/async/async.component.ts), so every <ng-template appAsyncLoaded let-p> consumer must cast. The rest are template union-narrowing workarounds.

Read first

  • src/app/shared/ui/async/async.component.ts (component + directives)
  • Consumers with $any: src/app/registratie/ui/dashboard.page.ts, registration-detail.page.ts, registration-summary/registration-summary.component.ts (×5, union peeking), registratie-wizard.component.ts (×4, step data), src/app/showcase/ui/concepts.page.ts (×2)

Decisions (pre-made, don't relitigate)

  • Fix the root cause with generics + static ngTemplateContextGuard, not per-consumer casts.
  • Fallback (only if Angular's inference fights the RemoteData<E,T> | Resource<T> union input): split into two typed inputs (data / resource) — record the swap here.

Files

  • src/app/shared/ui/async/async.component.tsAsyncComponent<T>; AsyncLoadedDirective<T> with static ngTemplateContextGuard<T>(dir, ctx): ctx is { $implicit: T } (same for the failure directive's error type if applicable)
  • Every $any() call site (grep -rn '\$any(' src/app)

Steps

  1. Make the async component/directives generic; keep the public API otherwise identical.
  2. Remove the now-unneeded $any()s in async consumers.
  3. Remaining union narrowing: replace with @switch on the status tag (registration-summary) or small typed computed() getters (wizard step data, showcase fake resource).
  4. npm run build (strict templates) is the real check here.

Acceptance criteria

  • grep -rn '\$any(' src/app → zero hits.
  • No as casts added to compensate in component classes (typed getters are fine).
  • Build green with strict template checking.

Verification

GREEN + npm run test-storybook:ci (one unrelated flake on review-section.stories.ts's smoke-test timeout, confirmed by re-running green — untouched by this WP). Manual smoke via a running docker compose stack + Playwright: logged in, drove /dashboard, /registratie (registration-detail), /aanvraag/:id, /concepts, and the /registreren wizard through the beroep step (both the DUO-match and the "mijn diploma staat er niet bij" handmatig branch) — every fixed template renders its real data with no console errors.

Deviation from the original plan

AsyncLoadedDirective<T> + static ngTemplateContextGuard was added (per the Decisions block) and is real, working generic typing for AsyncComponent's own internals. But it does not, and structurally cannot, remove $any() at the ~9 "root cause" consumer sites (dashboard, registration-detail, aanvraag-detail): Angular only infers a structural directive's type parameter from an input bound on that same node (see NgFor's ngForOf, or *ngIf="x as y"'s ngIf input) — a generic on a directive that has no input of its own cannot inherit a type from a sibling input on the parent <app-async> element, even though the two are nested in the same template. This is a hard limitation of Angular's template type-checker, not a gap in this implementation (confirmed against the documented ngTemplateContextGuard pattern and by the compiler continuing to type let-p as unknown after the generic was added).

The actual fix for those sites uses the WP's own sanctioned fallback wording ("typed getters are fine"): each consumer gets a small computed() that unwraps the RemoteData Success value, and the template narrows it locally with @if (x(); as p) inside the appAsyncLoaded slot (no let-p on the directive itself). registratie-wizard reused its existing duoData computed instead of adding a new one. The registration-summary union-narrowing case used the anticipated @switch fix, but needed a @let status = reg().status binding first — @switch/@case only narrows a stable local, not a repeated reg().status function call. The showcase fake-resource case (successRes) just reads successRes.value() directly in the @for, skipping let-v entirely.

AsyncComponent's public API ([data]/[resource] inputs) is unchanged, so this deviation is contained to consumer templates, as the WP intended.