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.
4.4 KiB
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.ts—AsyncComponent<T>;AsyncLoadedDirective<T>withstatic 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
- Make the async component/directives generic; keep the public API otherwise identical.
- Remove the now-unneeded
$any()s in async consumers. - Remaining union narrowing: replace with
@switchon the status tag (registration-summary) or small typedcomputed()getters (wizard step data, showcase fake resource). npm run build(strict templates) is the real check here.
Acceptance criteria
grep -rn '\$any(' src/app→ zero hits.- No
ascasts 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.