How to Type-Erase Routes in a SwiftUI Weather App

In this tutorial we will learn how to use type erasure so a SwiftUI NavigationStack can push different detail screens from a single path. Our example is a small weather app with two forecast destinations—a one-day forecast and a one-week forecast—each parameterized by latitude and longitude. The same pattern scales to as many screens as you need without growing a central enum of every destination in the app.
Getting Started
Imagine a home screen that lists saved places. Tapping a row can open either a 1-day forecast or a 1-week forecast for that place’s lat and lon. Each destination is its own SwiftUI view:
struct DailyForecastView: View {
let lat: Double
let lon: Double
var body: some View {
Text("1-day forecast")
Text("\(lat), \(lon)")
}
}
struct WeeklyForecastView: View {
let lat: Double
let lon: Double
var body: some View {
Text("1-week forecast")
Text("\(lat), \(lon)")
}
}
NavigationStack keeps a path of values and asks you for a view for each one. Those path values must share a single concrete type and conform to Hashable. If every screen used the same route type, that would be easy. We want each screen to own its own route type instead—so adding a forecast screen means adding a file, not editing a shared enum.
Defining Per-Screen Routes
Start with a protocol that every navigable screen conforms to. The associated Destination type is the SwiftUI view that route presents:
protocol AppRouting: Hashable {
associatedtype Destination: View
@ViewBuilder func destination() -> Destination
}
Each forecast gets a small struct that stores its inputs and builds its view:
struct DailyForecastRoute: AppRouting {
let lat: Double
let lon: Double
func destination() -> DailyForecastView {
DailyForecastView(lat: lat, lon: lon)
}
}
struct WeeklyForecastRoute: AppRouting {
let lat: Double
let lon: Double
func destination() -> WeeklyForecastView {
WeeklyForecastView(lat: lat, lon: lon)
}
}
At this point each route is strongly typed: a DailyForecastRoute can only produce a DailyForecastView, and the compiler knows the difference. What we do not yet have is a single type we can store in NavigationStack’s path. An array of any AppRouting looks tempting, but protocols with associated types cannot satisfy Hashable as existentials in the way a typed [AppRoute] path requires. We need a concrete wrapper that hides the underlying route type while still forwarding equality, hashing, and view building.
Type Erasing the Route
Type erasure means wrapping a value whose concrete type we do not want to expose, behind a concrete type that does expose the operations we care about. Here those operations are: build a destination view, compare for equality, and hash.
A private box protocol captures the erased surface. A generic RouteBox stores one concrete AppRouting value and implements that surface:
private protocol AppRouteBox {
func destination() -> AnyView
func hash(into hasher: inout Hasher)
func isEqual(to other: any AppRouteBox) -> Bool
}
private struct RouteBox<R: AppRouting>: AppRouteBox {
let route: R
func destination() -> AnyView {
AnyView(route.destination())
}
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(R.self))
hasher.combine(route)
}
func isEqual(to other: any AppRouteBox) -> Bool {
guard let other = other as? RouteBox<R> else {
return false
}
return route == other.route
}
}
ObjectIdentifier(R.self) keeps a one-day forecast for Brooklyn from colliding in the hash table with a one-week forecast for the same coordinates: different route types remain different path values even when their stored lat / lon match.
The public AppRoute type is what the navigation path actually stores. It holds one box and forwards everything:
struct AppRoute: Hashable {
private let box: any AppRouteBox
static func == (lhs: AppRoute, rhs: AppRoute) -> Bool {
lhs.box.isEqual(to: rhs.box)
}
func hash(into hasher: inout Hasher) {
box.hash(into: &hasher)
}
func destination() -> AnyView {
box.destination()
}
init(_ route: some AppRouting) {
box = RouteBox(route: route)
}
}
From the outside, AppRoute is just another Hashable value. Inside, it still knows how to present the concrete forecast view that was erased at init time. That is the whole trick: the associated type is used when the route is created, then sealed behind AnyView and the box so the path can stay homogeneous.
Wiring the Stack
Hold a path of erased routes and resolve each one with navigationDestination(for:):
struct WeatherApp: View {
@State private var path: [AppRoute] = []
var body: some View {
NavigationStack(path: $path) {
List {
Button("Brooklyn · 1 day") {
path.append(
AppRoute(
DailyForecastRoute(lat: 40.6501, lon: -73.9496)
)
)
}
Button("Brooklyn · 1 week") {
path.append(
AppRoute(
WeeklyForecastRoute(lat: 40.6501, lon: -73.9496)
)
)
}
}
.navigationTitle("Places")
.navigationDestination(for: AppRoute.self) { route in
route.destination()
}
}
}
}
Pushing a one-day forecast and a one-week forecast uses the same append call site. The path does not need to know which forecast it holds—only that whatever it holds can produce a view when asked. Adding a third destination later (for example an hourly breakdown) is another AppRouting conformer plus another button; the stack, the path type, and the destination modifier stay unchanged.
A small helper keeps call sites tidy when many screens push routes:
extension Binding where Value == [AppRoute] {
func open(_ route: some AppRouting) {
wrappedValue.append(AppRoute(route))
}
}
Then a row can write path.open(DailyForecastRoute(lat: lat, lon: lon)) without mentioning the eraser at every call site.
Why Not a Single Enum?
An enum AppRoute with cases for every screen also gives you a homogeneous Hashable path. That works well when the app is small. As the number of screens grows, every new destination becomes a change to the shared enum and to the giant switch that builds views. Per-screen types keep each destination local: the one-day forecast owns its inputs and its view, and type erasure is the seam that lets those local types share one navigation path.
AnyView itself is a form of type erasure for views. We use it at the boundary where the path must forget the concrete Destination associated type. Inside each route file, you still return a real DailyForecastView or WeeklyForecastView—the erasure happens once, when the route is boxed for the stack.
Conclusion
We’ve seen how per-screen route types keep forecast destinations local, why a NavigationStack path still needs one concrete Hashable type, and how a small box-and-wrapper pair type-erases those routes so one-day and one-week forecasts can share the same path. With AppRouting, AppRoute, and a single navigationDestination(for: AppRoute.self), you can grow a weather app’s navigation screen by screen without centralizing every destination in one enum.
Cover image generated for this post.
