# 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>` 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

- [x] `grep -rn '\$any(' src/app` → zero hits.
- [x] No `as` casts added to compensate in component classes (typed getters are fine).
- [x] 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.
