# 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:

```swift
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:

```swift
protocol AppRouting: Hashable {
  associatedtype Destination: View

  @ViewBuilder func destination() -> Destination
}
```

Each forecast gets a small struct that stores its inputs and builds its view:

```swift
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:

```swift
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:

```swift
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:)`:

```swift
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:

```swift
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.*
