Eliminating Broken Links with Type-Safe Routing in PureScript
Stop relying on fragile string concatenation for URLs. Learn how to use purescript-routing-duplex to create a bidirectional, type-safe routing system that catches broken links at compile time.
17 Aug 2026, 20:51 UTC

The Cost of String-Based Routing
In most web applications, routing is handled by matching a URL string to a view. When you need to link to a user profile, you might write "/user/" ++ userId. This approach creates a fragile dependency: if you change the route definition to /profile/, your compiler won't warn you about the broken link. You only discover the 404 error during manual testing or, worse, after a user reports it.
The solution is to treat routes as data rather than strings. By using the purescript-routing-duplex library, you can define a single source of truth for your application's URLs. This ensures that if a route changes, every single link in your application that references that route will cause a compile-time error until updated.
The Duplex Pattern: Bidirectional Mapping
Traditional routing libraries usually provide a parser (URL $\rightarrow$ Route). Duplex routing provides both a parser and a printer (Route $\rightarrow$ URL) derived from the same definition. This bidirectional mapping is what guarantees correctness.
You start by defining a Sum Type—a data type that can be one of several different variants—to represent every possible page in your app. Because PureScript enforces exhaustive pattern matching, the compiler will force you to handle every single route variant in your view logic, leaving no "forgotten" pages.
Implementing a Type-Safe Route
To implement this, you use a combinator-based DSL. Combinators are small, reusable functions that build up a complex structure. In this case, they build a mapping between a data constructor and a URL path.
-- Required imports (assumes purescript-routing-duplex)
import Routing.Duplex (Route, makeRoute, parse, render)
import Routing.Duplex.Combinators (lit, param)
import Data.Int (Int)
-- 1. Define the Route data type
data AppRoute
= Home
| UserProfile Int
| Settings
-- 2. Define the duplex mapping
appRoute :: Route AppRoute
appRoute = makeRoute
[ lit "/" Home
, param "/user/" UserProfile
, lit "/settings" Settings
]
How to use this in your application
Instead of manually typing strings, you now use the render function to generate URLs and the parse function to handle incoming requests. Run these operations in your main application loop or within a framework like Halogen.
- Generating a link:
render appRoute (UserProfile 42)results in"/user/42". - Parsing a URL:
parse appRoute "/user/42"results inJust (UserProfile 42). - Handling invalid paths:
parse appRoute "/unknown"results inNothing, allowing you to trigger a 404 view safely.
Trade-offs and Limitations
While type-safe routing eliminates a massive class of bugs, it introduces specific engineering overhead:
- Boilerplate for Query Params: Path segments (like
/user/123) are straightforward. However, optional query parameters (like?sort=desc) require more complex type definitions and additional boilerplate to handle the optionality. - Compilation Overhead: In extremely large applications with hundreds of nested routes, the deeply nested types generated by the duplex combinators can increase compilation times.
- Dependency Management: To avoid circular dependencies, you must keep your
Routedata type in a standalone module. If your route depends on a type defined in a View module, and the View module needs the Route to navigate, you will hit a circular import error.
Verifying Your Routing Logic
To verify your implementation, you can perform a simple check in your REPL or a test suite:
- Round-trip Test: Ensure that
parse appRoute (render appRoute routeValue)always returns the originalrouteValue. - Refactor Test: Change
lit "/settings" Settingstolit "/account" Settings. Observe that while theSettingsconstructor remains the same, the generated URLs across your entire app update automatically. - Type Check: Add a new constructor to
AppRoute(e.g.,AdminPanel) without adding it to themakeRoutelist. The compiler will flag the missing mapping, ensuring your data type and your routing logic stay in sync.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.