0 day streak
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.