javascript / expert
Snippet
Discriminated Route Data via Resolver-Backed Type Narrowing
A resolver that throws on a 403 or 404 forces every consuming component into a generic router error page, discarding the specific reason the navigation should be handled differently. Returning a discriminated union instead lets `ActivatedRoute.data` carry a typed `kind` field the component template can `@switch` on, so a paywall message and a not-found message stay distinct without a second round-trip. This trades the router's built-in error-handling pipeline for explicit, exhaustively-checked branching in the component — the resolver never rejects, so `withComponentInputBinding` or a route guard can't accidentally swallow the failure reason.
snippet.js
javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { ResolveFn } from '@angular/router';import { inject } from '@angular/core';type LoadResult =| { kind: 'ok'; payload: { id: string; title: string } }| { kind: 'forbidden'; reason: string }| { kind: 'missing' };export const articleResolver: ResolveFn<LoadResult> = async (route) => {const id = route.paramMap.get('id');if (!id) {return { kind: 'missing' };}const service = inject(ArticleGateway);const res = await service.fetchRaw(id);if (res.status === 403) {return { kind: 'forbidden', reason: 'not-subscribed' };}if (res.status === 404) {return { kind: 'missing' };}return { kind: 'ok', payload: res.body };};declare class ArticleGateway {fetchRaw(id: string): Promise<{ status: number; body: { id: string; title: string } }>;}
angular
Breakdown
1
type LoadResult = { kind: 'ok'; ... } | { kind: 'forbidden'; ... } | { kind: 'missing' };
The shared `kind` literal field is what TypeScript uses to narrow the union inside a `@switch` block in the consuming template, without a manual type guard function.
2
if (res.status === 403) { return { kind: 'forbidden', reason: 'not-subscribed' }; }
Converts an HTTP status into domain-level data rather than an exception, keeping the resolver's promise always fulfilled so `Router` navigation always completes.
3
export const articleResolver: ResolveFn<LoadResult> = async (route) => {
The functional `ResolveFn` (replacing the deprecated class-based `Resolve` interface) types its return as the union directly, so `route.snapshot.data['article']` is typed end-to-end.