In an Angular order-status banner, how do you make the build fail when a new OrderStatus value has no @case, and why does @switch (order().status) defeat it?
answer
- a default that claims nothing is left
- the type checker assigns to never
- narrowing needs a stable reference
- name the signal value first
basics
~20 sEnd the @switch with @default never; so template type checking reports any unhandled union member. It relies on TypeScript narrowing, which does not work on a call like order().status, so read the signal into @let first.
solid answer
~40 sSince v21.2, `@switch` accepts `@default never;`. It renders nothing; instead the template type checker emits, for the default clause, an assignment of the switch value to a `never`-typed variable. If every member of the union has an `@case`, TypeScript has narrowed the value to `never` there and the check passes. Add `'refunded'` to `OrderStatus` without a case, and the build fails at that template. The check depends on TypeScript's narrowing, which only works on stable references, not on calls, so `@switch (order().status)` is not narrowed and cannot prove anything. Read the signal first: `@let o = order();` then `@switch (o.status)`. When switching on a discriminant of an object union, name the union to check with `@default never(o);`, supported since v22.0. The check needs template type checking, which `strictTemplates` turns on fully.
code
ts · 22 linesimport { Component, input } from '@angular/core';
type Order =
| { status: 'pending' }
| { status: 'shipped'; trackingNumber: string }
| { status: 'cancelled'; reason: string };
@Component({
selector: 'app-order-status-banner',
template: `
@let o = order();
@switch (o.status) {
@case ('pending') { <p class="banner">Waiting for payment.</p> }
@case ('shipped') { <p class="banner">Tracking: {{ o.trackingNumber }}</p> }
@case ('cancelled') { <p class="banner banner--error">Cancelled: {{ o.reason }}</p> }
@default never(o);
}
`,
})
export class OrderStatusBanner {
order = input.required<Order>();
}go deeper
Recall that @switch can end with @default never; to catch missing cases.
Explain how the type-check code assigns the value to never and why narrowing is needed.
Diagnose why the check fails on signal calls and restructure with @let and never(o) for object unions.
Decide how template exhaustiveness fits into contract changes with the backend, since it proves nothing at runtime.
## The problem it solves A status banner maps every value of a union type to some markup: ```ts type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled'; ``` Later someone adds `'refunded'`. TypeScript code that switches on the status can be made to fail at compile time, but a template `@switch` without a check silently renders nothing (or the `@default`) for the new value. The banner simply disappears for refunded orders, and nobody notices until a customer does. ## `@default never;` Since **v21.2**, a `@switch` can end with a special default: ```html @let status = order().status; @switch (status) { @case ('pending') { <p class="banner">Waiting for payment.</p> } @case ('paid') { <p class="banner">Preparing your order.</p> } @case ('shipped') { <p class="banner">On its way.</p> } @case ('cancelled') { <p class="banner banner--error">Cancelled.</p> } @default never; } ``` It has **no runtime output**. Its effect is in the code Angular generates to type-check the template: a `switch` with the same cases, plus a default clause that assigns the switch value to a variable typed `never`. Because TypeScript narrows a value inside `switch`, in the default clause: - if every union member has a case, the value's type there is `never`, the assignment is valid, and compilation succeeds; - if a member is missing, the value's type there is that member (for example `'refunded'`), which is not assignable to `never`, and the compiler reports an error at the template. `@default never;` counts as the block's one `@default`, so it cannot be combined with a normal `@default`. That is the point: you are declaring that no default case exists. ## Why `order().status` defeats it Narrowing is something TypeScript does to **references** such as a local variable or a property path. It does not narrow the result of a **function call**, because calling twice may return different things. A signal read is a call, so in: ```html @switch (order().status) { ... @default never; } ``` each `order().status` is typed afresh as the full `OrderStatus`, the default clause never sees `never`, and the check cannot pass even when every case is present. The Angular docs call this out: exhaustiveness checking does not work when the switch condition is a function call or a signal. The fix is to **read the value once into a template variable** and switch on that: 1. `@let status = order().status;` creates a read-only, view-scoped variable. 2. `@switch (status)` then switches on a stable reference, which TypeScript narrows. The same technique gives narrowing inside the case bodies. ## Object unions: `@default never(expr)` Often the status is the discriminant of an object union: ```ts type Order = | { status: 'pending' } | { status: 'shipped'; trackingNumber: string } | { status: 'cancelled'; reason: string }; ``` With `@let o = order();` and `@switch (o.status)`, each `@case` body sees `o` narrowed, so `{{ o.trackingNumber }}` type-checks inside `@case ('shipped')`. For exhaustiveness, name the value whose remaining type should be `never`: `@default never(o);`. The parenthesised form arrived in **v22.0**; the docs require it when the switched expression is nested within a union. ## Rolling it out in an existing codebase 1. Find the switches over union types that matter most: status banners, badges, icons chosen by type. 2. Where the switch reads a signal, introduce `@let` for the value and switch on the variable. 3. Replace an existing `@default` that only renders a placeholder with `@default never;` (or `never(o)` for object unions) once every real value has a case. 4. Keep a real `@default` where unknown values are expected at runtime and need visible handling; exhaustiveness and a runtime fallback are alternatives, not a pair. 5. Add a test per status so the runtime behaviour is covered as well as the types. The payoff shows up months later: adding a status to the shared type makes the build point to every template that needs a new case. ## Requirements and limits | Requirement | Why | |---|---| | Template type checking enabled (`strictTemplates` in new CLI projects) | the check lives in the type-check code, not at runtime | | A switch expression TypeScript can narrow | calls and signal reads are not narrowed | | A union type with literal members | a plain `string` can never be exhausted | - It proves coverage at **build** time; if an unexpected value arrives at runtime (a new status from a newer backend), nothing renders. Pair it with API types generated from the backend contract. - `@if` chains have no equivalent check; prefer `@switch` for enum-like values.
- Can an Angular @switch have both @default never; and a normal @default block?No. The parser allows only one default, and `@default never;` counts as it. They also contradict each other: one says no other values exist, the other renders something for them.
- Does Angular's @default never; protect against an unexpected status arriving at runtime?No. It is a compile-time proof against the declared type only and renders nothing. If a backend sends a value the type does not list, no case matches and nothing is shown, so the API types must be kept in step with the backend.
saying these in an interview costs you the question
- @default never; renders a fallback when no case matches
- Exhaustiveness checking works directly on @switch (order().status)
- @default never; is checked at runtime by Angular
- A plain string status can be checked exhaustively
- Adding a normal @default next to @default never; is fine