Route guards
A guard is a bare function* on canActivate / canMatch. It yields what it needs, and returns either a value or a craftException describing why the route must not render.
Use one when access to a route depends on state: authentication, a role, a feature flag, an onboarding step. Not when the answer is a redirect with no condition — that is a static route.
Guards here are reusable and parameterised, they compose inside a single canActivate / canMatch, and their failure cases are resolved exhaustively — an unhandled case is a type error.
A guard is just a generator function.
canActivate/canMatchtake a barefunction* () { … }directly — there is nocraftCanActivate/craftCanMatchwrapper and no inlineresolversargument. Every reachablecraftExceptionis resolved by a single, exhaustivehandleExceptionsmap on the route, applied after the URL commits by the non-blockingCraftRouterOutlet.
The problem
A craftRoutes canActivate accepts a single function (or generator function). To apply several authorization rules — role, account state, feature flag… — you have to inline everything into one generator and hand-roll each rejection by returning a createUrlTree(...):
canActivate: function* () {
const { user } = yield* CraftAuth(undefined, ({ user }) => ({ user }));
if (!user()) {
return createUrlTree(['/auth/login']); // not authenticated
}
if (user()!.role !== 'admin') {
return createUrlTree(['/unauthorized']); // wrong role
}
const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({ pizzeria }));
if (pizzeria()) {
return createUrlTree(['/dashboard']); // already onboarded
}
return true;
}The rules are not reusable, the redirect logic is tangled with the checks, and nothing forces you to handle every rejection — forget a branch and it silently falls through.
The solution: craftGen + a composing generator guard
Split the two concerns:
craftGenauthors a reusable, parameterised guard. It either returns a success value or a typedcraftException.- The route's
canActivategenerator composes guards withyield*; the route's exhaustivehandleExceptionsmap must cover exactly the reachable exception codes.
For a focused overview of craftGen itself and why it is useful, see craftGen.
import {
craftException,
craftGen,
craftResolve,
CraftHttpClient,
query,
craftRoute,
craftUntilSettled,
} from '@craft-ng/core';
// Reusable guards — each returns a success value | craftException(...)
const roleGuard = craftGen(
(...roles: Role[]) =>
function* () {
const { user } = yield* CraftAuth(undefined, ({ user }) => ({
user,
}));
if (!user()) return craftException({ code: 'NOT_AUTHENTICATED' });
return roles.includes(user()!.role)
? true
: craftException({ code: 'FORBIDDEN_ROLE' });
},
);
const noPizzeriaGuard = craftGen(
() =>
function* () {
const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({
pizzeria,
}));
return pizzeria() ? craftException({ code: 'HAS_PIZZERIA' }) : true;
},
);
const { pizzeriaDraftQuery } = query('pizzeriaDraftQuery', {
params: () => true,
loader: function* () {
return yield* CraftHttpClient.get(({ response }) => ({
url: '/api/pizzerias/draft',
success: response<PizzeriaDraft>(),
exceptions: [
function* ({ status }) {
if (!(yield* status(404))) return;
return craftException({ code: 'PIZZERIA_DRAFT_UNAVAILABLE' });
},
],
}));
},
});
craftRoute(
'new',
{
title: 'Create Pizzeria',
canActivate: function* () {
yield* roleGuard(ROLES.PIZZERIA_ADMIN); // short-circuits on exception
yield* noPizzeriaGuard();
return true;
},
resolve: craftResolve(function* () {
return yield* craftUntilSettled(pizzeriaDraftQuery);
}),
loadComponent: ({ withRetry }) =>
withRetry(
import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page'),
).then((m) => m.AdminPizzeriaFormPage),
componentDeps:
{} as import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page').GenDeps_AdminPizzeriaFormPage,
},
{
// Resolved centrally — exhaustive over canActivate ∪ canMatch ∪ resolve.
NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {
return yield* redirectTo({ to: 'auth/login' });
}),
FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) {
return yield* redirectTo({ to: 'unauthorized' });
}),
HAS_PIZZERIA: craftExceptionHandler(function* ({ redirectTo }) {
return yield* redirectTo({ to: 'pizzerias/admin' });
}),
PIZZERIA_DRAFT_UNAVAILABLE: craftExceptionHandler(function* ({
globalError,
}) {
return globalError();
}),
HttpError: craftExceptionHandler(function* ({ globalError }) {
return globalError();
}),
},
);
// After the collection is defined, assert every route handles exactly its codes:
// assertExhaustiveRouteExceptions(adminRoutes);Reactive guards
While a route is active, its canActivate invariant stays under observation (live guards, on by default). If a signal the guard reads changes — e.g. the user logs out and Auth becomes null — the guard re-evaluates synchronously and applies handleExceptions with phase: 'active', so the target is never left rendered in an incoherent state. The reactive phase never re-runs resolve (no new pending). Opt out per route with reactiveGuards: false.
How composition works
craftGen(factory) returns a factory you invoke and delegate to with yield*:
- The guard's dependency yields (
CraftAuth,CraftRouter, …) flow up to the route exactly as in a plain generator guard, so cascade DI tracking still sees them. - As soon as a composed guard produces a
craftException, the enclosing generator short-circuits:yield* roleGuard(...)interrupts the wholefunction*, and the exception is propagated to the route's guard boundary — noif/returnplumbing in the composing guard. - The set of exceptions each guard can produce is tracked at the type level, so the route's
handleExceptionsmap knows precisely which codes it must handle.
Order matters: guards run top-to-bottom and the first exception wins (fail-fast).
The handler context
Each route exception handler receives the typed exception and payload, the navigation phase, the native Angular Router helpers, and the five outcome constructors. See Centralised Exception Handling for the exhaustive list and examples.
Use redirectTo(...) for typed internal routes:
{
RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) {
return yield* redirectTo({
to: 'cooldown',
queryParams: { retryAfter: String(payload.retryAfter) },
});
}),
}A handler returns a CraftExceptionOutcome via redirectTo, redirectUrl, renderComponent, globalError, stay, or noop. The payload is taken from craftException({ code }, payload)'s second argument and typed per code.
Handlers can yield services
A handler may be a generator that yield*s craft services before building the redirect — for example to read the login URL from a config service. Those yields are tracked exactly like the guards' own dependencies, so a service used only at redirect-time still flows into the route's cascade DI (yield an unprovided service and it surfaces as a missing-provider error on the route):
craftRoute(
'admin',
{
canActivate: function* () {
yield* roleGuard(ROLES.ADMIN);
return true;
},
},
{
// Generator handler — `RedirectConfig` becomes a tracked route dependency.
FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) {
const { unauthorizedUrl } = yield* RedirectConfig();
return redirectUrl(unauthorizedUrl);
}),
},
);Every handler uses the generator wrapper, including handlers that do not yield a service.
Exhaustiveness
The handler map is typed over the reachable codes, so every reachable code must be handled — a missing one is a type error:
craftRoute(
'admin',
{ canActivate: guard },
{
FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) {
return yield* redirectTo({ to: 'unauthorized' });
}),
// Type error: Property 'HAS_PIZZERIA' is missing.
},
);Add a guard that can raise a new code, and every route using it stops compiling until its handler is added. A typo'd code is caught the same way because the correctly-spelled key is then missing.
Guarded data still flows through
A canActivate guard's success value (anything other than true/UrlTree/…) becomes the route's guarded data — craftException returns are never treated as data:
const authGuard = craftGen(
() =>
function* () {
const user = yield* Auth();
const userValue = user.value();
return userValue
? userValue
: craftException({ code: 'NOT_AUTHENTICATED' });
},
);
craftRoute(
'query/:userId',
{
componentDeps: {} as import('./query').GenDeps_GlobalQuery,
loadComponent: ({ withRetry }) => withRetry(import('./query')),
canActivate: function* () {
return yield* authGuard(); // success value = the user
},
},
{
NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectUrl }) {
return redirectUrl('/login-form');
}),
},
).withProviders(({ GuardedData }) => [
provideUser(function* () {
return (yield* GuardedData())(); // Signal<User> → User
}),
]);canMatch
canMatch is the sibling of canActivate — same composition and exhaustive resolution through handleExceptions. Unlike canActivate, a canMatch guard produces no guarded data.
const featureFlagGuard = craftGen(
(flag: string) =>
function* () {
const { flags } = yield* CraftConfig();
return flags[flag] ? true : craftException({ code: 'FLAG_DISABLED' });
},
);
craftRoute(
'beta',
{
componentDeps: {} as import('./beta').GenDeps_Beta,
loadComponent: ({ withRetry }) => withRetry(import('./beta')),
canMatch: function* () {
yield* featureFlagGuard('beta');
return true;
},
},
{
FLAG_DISABLED: craftExceptionHandler(function* ({ redirectUrl }) {
return redirectUrl('/home');
}),
},
);Async guards
The guards above are synchronous — every craftGen resolves in one pass. To decide based on data that has to be fetched first, suspend the composing guard with craftUntilSettled (or craftUntilDefined). The guard stays a normal generator: yield* a(); const x = yield* craftUntilSettled(...); yield* b() composes across the await, and the awaited operation's craftExceptions flow into the same exhaustive handleExceptions map — the compiler still forces you to handle every reachable code.
craftUntilSettled — await a resource or an HTTP call
craftUntilSettled takes either a craft resource (query / mutation / asyncProcess) or a CraftHttpClient.* call and suspends until it settles, then returns its success value.
craftRoute(
'users/:userId',
{
componentDeps: {} as import('./user').GenDeps_User,
loadComponent: ({ withRetry }) => withRetry(import('./user')),
canActivate: function* (route) {
const userId = route.params['userId'];
// (a) Await an HTTP call directly — no named resource needed. Its declared
// `exceptions` flow into the route's handleExceptions below.
const user = yield* craftUntilSettled(
CraftHttpClient.get(({ response }) => ({
url: `/api/users/${userId}`,
success: response<User>(),
exceptions: [
function* ({ status, code }) {
if (!(yield* status(400))) return;
if (!(yield* code('PASSWORD_REQUIRED'))) return;
return craftException({
code: 'PASSWORD_REQUIRED',
scope: 'UsersFeature',
});
},
],
})),
);
return user.active ? true : craftException({ code: 'INACTIVE_USER' });
},
},
{
// Both the guard's own exception AND the HTTP call's exception are required.
INACTIVE_USER: craftExceptionHandler(function* ({ redirectUrl }) {
return redirectUrl('/inactive');
}),
PASSWORD_REQUIRED: craftExceptionHandler(function* ({ redirectUrl }) {
return redirectUrl('/password');
}),
},
);The resource form is identical — pass the ref (an inline query(name, ...) works, though it is reactive; prefer the HTTP form for one-shots):
const user =
yield *
craftUntilSettled(
query('user', {
params: () => userId,
loader: ({ params }) => fetchUser(params),
}).user,
);Settle semantics & exception routing:
- A resource settles when its
statusreaches'resolved'or'error'. A loadercraftExceptionshort-circuits tohandleExceptions; a thrown loader error is rethrown; otherwise the resolved value is returned. - An HTTP call's declared business
exceptionsshort-circuit tohandleExceptions. The generic transport-levelHttpError(scope: 'HttpClient') is rethrown — a network failure is not a resolvable business case. (An opt-inHttpErrorhandler may come later.) - The awaited HTTP endpoint is tracked as a route dependency automatically, exactly like one used in a component or loader.
craftUntilDefined — await a readiness signal
craftUntilDefined(signal) suspends until signal() is no longer undefined, then returns its non-nullable value. There is no exception channel — use it to wait on a plain readiness signal.
const session = yield * craftUntilDefined(sessionService.current);Notes
- A guard that never reaches an
craftUntilSettled/craftUntilDefinedawait still resolves synchronously (no forced microtask) — existing synchronous guards are unchanged. - This works for both
canActivateandcanMatch; the outlet drives the guard to settlement after the URL commits.
Exceptions
Guards fail with craftException({ code }, payload?) — the same typed-exception primitive used by query / mutation:
craftException({ code: 'FORBIDDEN_ROLE' });
craftException({ code: 'RATE_LIMITED' }, { retryAfter: 30 }); // payload reaches the handlerThe code drives both the exhaustiveness check and the handler lookup; the optional payload is typed and forwarded to the handler.
When to reach for it
craftGen + a canActivate / canMatch generator fit sequential, fail-fast gates resolved at a single boundary: authorization, account-state checks, feature flags, action preconditions.
It is not the right tool when you want to collect and surface multiple failures reactively — that is what query / mutation hasException and the form-submit exception model are for. Guards stop at the first failure and hand off to a handler.
See Also
- Route Providers — consume guarded data in route providers
- Setup — the app-wide cascade DI check
- craftService — services yielded inside guards