Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Routing & type-safe DI
  2. Route exception handling

Route exception handling#

When a guard, a matcher or a resolver raises a declared exception, this page is where you say what happens next: redirect, render a dedicated component, stay put, or carry on. One map per route resolves the union of every code those three steps can produce — and the compiler checks that the map is exactly complete, no more and no less.

Use it when a route's guards, matchers or resolvers can fail in ways the user should see. Not when the failure is local to one primitive — read it off exceptions() instead, see Exceptions as values.

Breaking change

Every handler must use craftExceptionHandler(function* (...) {}). Internal redirects use yield* redirectTo({ to, params, queryParams, viewTransition }); opaque URLs or prebuilt UrlTree values use redirectUrl(...). renderComponent, route-level errorComponent and withErrorComponent accept only { component | loadComponent, componentDeps } descriptors. Bare handler functions, redirect(...) and bare error components are rejected.

The common case#

ts
USER_DISABLED: craftExceptionHandler(function* ({ renderComponent }) {
  return renderComponent({
    loadComponent: () =>
      import('./user-disabled-error-page').then(
        (m) => m.UserDisabledErrorPage,
      ),
    componentDeps:
      {} as import('./user-disabled-error-page').GenDeps_UserDisabledErrorPage,
  });
}),

NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {
  return yield* redirectTo({
    to: 'auth/login',
    queryParams: { reason: 'session-expired' },
  });
}),

canActivate / canMatch / resolve stay your writing API — each may raise a typed craftException. Instead of an inline resolver map per guard, a single handleExceptions map on the route resolves the union of every code reachable from those three steps. The non-blocking CraftRouterOutlet applies the chosen outcome after the URL has committed, so a slow guard never freezes navigation.

The route result also exposes a route-scoped signal helper per code, such as injectDemoUserIdUserDisabledException(). It returns the exact exception and payload for the locally rendered branch, and is cleared on the next navigation.

A full route, end to end#

ts
const { profileQuery } = query('profileQuery', {
  params: () => true,
  loader: function* () {
    return yield* CraftHttpClient.get(({ response }) => ({
      url: '/api/profile',
      success: response<Profile>(),
      exceptions: [
        function* ({ status, code }) {
          if (!(yield* status(403))) return;
          if (!(yield* code('USER_DISABLED'))) return;
          return craftException({ _tag: 'USER_DISABLED' });
        },
      ],
    }));
  },
});

craftRoute(
  'user/:userId',
  {
    loadComponent: ({ withRetry }) => withRetry(import('./user-detail')),
    componentDeps: {} as import('./user-detail').GenDeps_UserDetail,
    canMatch: function* () {
      const ff = yield* FeatureFlags();
      return ff.userPageEnabled ? true : craftException({ _tag: 'FEATURE_OFF' });
    },
    canActivate: function* () {
      const user = yield* Auth();
      return user.value() ?? craftException({ _tag: 'NOT_AUTHENTICATED' });
    },
    resolve: craftResolve(function* () {
      return yield* craftUntilSettled(profileQuery);
    }),
  },
  {
    FEATURE_OFF: craftExceptionHandler(function* ({ redirectTo }) {
      return yield* redirectTo({ to: 'home' });
    }),
    NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo, phase }) {
      return yield* redirectTo({
        to: 'login',
        queryParams: phase === 'active' ? { reason: 'session-expired' } : {},
      });
    }),
    USER_DISABLED: craftExceptionHandler(function* ({ globalError }) {
      return globalError();
    }),
    HttpError: craftExceptionHandler(function* ({ globalError }) {
      return globalError();
    }),
  },
),

canActivate / canMatch are bare generator functions — there is no guard wrapper and no inline resolvers argument. Every reachable code flows to the third craftRoute(...) argument.

Handler context#

Every handler receives a CraftExceptionHandlerContext typed for its exception code:

FieldType / purpose
exceptionThe complete typed craftException, including code, scope, and payload.
payloadThe typed payload passed as the second argument of craftException(...).
phase'enter' during initial activation, 'active' during a live guard re-check.
routerThe active Router instance.
createUrlTreeBound Router.createUrlTree, useful for building a redirect with query params or fragments.
navigateBound Router.navigate. Imperative; prefer returning yield* redirectTo(...).
navigateByUrlBound Router.navigateByUrl. Imperative; prefer a redirect outcome.
redirectToTyped internal redirect checked against META_PATHS; yields CraftRouter.
redirectUrlExplicit escape hatch for an opaque string URL or UrlTree.
renderComponentBuilds an outcome that renders a dedicated component.
globalErrorDelegates rendering to the application-wide error component.
stayRestores the previous URL and keeps the triggering page.
noopContinues to the target despite the exception. Resolve data remains undefined.

A handler is always a synchronous generator wrapped with craftExceptionHandler. It may resolve services but cannot suspend with craftUntilSettled / craftUntilDefined.

Outcomes#

Each handler receives a context and returns an outcome constructor:

OutcomeEffect
yield* redirectTo(input)Navigate to a registered internal route with typed params/query params/view transition.
redirectUrl(target)Navigate to an opaque string URL or UrlTree.
renderComponent(descriptor)Render a DI-checked { component | loadComponent, componentDeps } descriptor.
globalError()Render the application-wide error component (see global error component).
stay()Cancel the navigation; restore the previous URL (stay on the triggering page).
noop()Render the target anyway, with resolve data left undefined.

The context also carries the typed exception, its payload, the native redirect helpers (createUrlTree / navigate / navigateByUrl), and the navigation phase (see below). A handler may be a generator that yield*s craft services before its outcome.

Examples#

Typed payload and UrlTree#

Use redirectTo(...) for registered application routes and redirectUrl(...) for a prebuilt UrlTree:

ts
{
  NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {
    return yield* redirectTo({ to: 'auth/login' });
  }),
  RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) {
    return yield* redirectTo({
      to: 'cooldown',
      queryParams: { retryAfter: String(payload.retryAfter) },
    });
  }),
}

Here payload is inferred from craftException({ _tag: 'RATE_LIMITED' }, { retryAfter: 30 }).

Initial entry versus live guard#

ts
{
  NOT_AUTHENTICATED: craftExceptionHandler(function* ({ phase, redirectTo }) {
    return yield* redirectTo({
      to: 'login',
      queryParams: phase === 'active' ? { reason: 'session-expired' } : {},
    });
  }),
}

Local, global, stay, and noop outcomes#

ts
{
  ACCOUNT_LOCKED: craftExceptionHandler(function* ({ renderComponent }) {
    return renderComponent({
      component: AccountLockedPage,
      componentDeps: {} as import('./account-locked-page').GenDeps_AccountLockedPage,
    });
  }),
  MAINTENANCE: craftExceptionHandler(function* ({ renderComponent }) {
    return renderComponent({
      loadComponent: () => import('./maintenance-page').then((m) => m.MaintenancePage),
      componentDeps: {} as import('./maintenance-page').GenDeps_MaintenancePage,
    });
  }),
  HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); }),
  UNSAVED_CHANGES: craftExceptionHandler(function* ({ stay }) { return stay(); }),
  OPTIONAL_PROFILE_UNAVAILABLE: craftExceptionHandler(function* ({ noop }) { return noop(); }),
}

The descriptor is checked independently with the O(1) RouteExceptionComponentCheckedDI; it is not part of the routed component's RouteCheckedDI proof. Architecture tests fail if that proof is missing or not armed with CanRun.

Handler using a craft service#

ts
{
  FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) {
    const config = yield* RedirectConfig();
    return redirectUrl(config.unauthorizedUrl);
  }),
}

Dependencies yielded by handlers participate in route DI checking, like dependencies yielded by guards and resolvers.

Exhaustiveness#

The union is only resolvable once the whole collection is inferred, so exhaustiveness is asserted after craftRoutes rather than inline on each route:

ts
export const { demoRoutes } = craftRoutes('demo', [
  /* … */
]);

// Compile error if any route's handleExceptions misses — or over-covers — a reachable code.
assertExhaustiveRouteExceptions(demoRoutes);

Architecture tests fail if a craftRoutes(...) collection has no assertExhaustiveRouteExceptions.

A missing code (e.g. resolve can throw USER_DISABLED but no handler) and an extra code (a handler for a code nothing can produce) are both type errors, naming the offending route + codes.

Pitfalls#

HttpError appears or disappears depending on the craftUntilSettled form. This is the most common surprise:

  • craftUntilSettled(CraftHttpClient.get(...)) excludes HttpError from the routable union and rethrows it. The outlet sends that navigation error to the global error component.

  • craftUntilSettled(queryRef) routes every exception the query exposes. When its loader returns a CraftHttpClient request, that includes HttpError, so the route must declare an explicit handler such as HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); }).

Declared business exceptions remain routable in both forms.

A handler cannot suspend. It may yield* services, but not craftUntilSettled / craftUntilDefined.

Over-covering is an error too. A handler for a code nothing can produce fails the exhaustiveness assert, same as a missing one.

The `phase` field

phase distinguishes the initial activation ('enter') from a reactive re-evaluation ('active') of a live canActivate guard (see live guards). Use it to soften a reaction mid-session — a different redirect reason on session expiry, say — or ignore the reactive phase entirely with noop().

See Also#

  • Exceptions as values — the concept

  • Route guards — where exceptions are raised

  • Global error component

  • Architecture rules — assertRouteDiProofs keeps the exhaustiveness assert in place

←Route guardsNon-blocking navigation→

On this page

  • The common case
  • A full route, end to end
  • Handler context
  • Outcomes
  • Examples
  • Typed payload and UrlTree
  • Initial entry versus live guard
  • Local, global, stay, and noop outcomes
  • Handler using a craft service
  • Exhaustiveness
  • Pitfalls
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search