🚚 Typed Route Path Management in an Angular Application

The Problem

If you’ve worked in any Angular application that was made in the last few years, you’ve probably seen some code that looks a little like this inside the application’s app or routing module:

const routes: Routes = [
  { path: 'first-component', component: FirstComponent },
  { path: 'second-component', component: SecondComponent },
  { 
    path: 'third-component-with-children',
    component: ThirdComponent,
    children: [
      { path: 'first-child-component', component: FirstChildComponent },
      { path: 'second-child-component', component: SecondChildComponent },
    ]
   },
  { path: '**', component: FallbackComponent },
];

This is our application’s routing config, and it gets used when we import the RouterModule into our module:

// Main module
@NgModule({
  // ...
  imports: [
    RouterModule.forRoot(routes),
  ]
})

// Or a lazy-loaded module
@NgModule({
  imports: [
    RouterModule.forChild(routes)
  ]
})

We can then navigate to those routes using the Router service in our component:

// ...

constructor(private router: Router) {
  this.router.navigateByUrl('first-component');
}

// ... 

Or we can use the routerLink directive in our template:

<a routerLink="first-component">Link to first component</a>

While this is the structure that Angular recommends in its documentation for setting up your routes, its major drawback is the heavy use of hardcoded strings. What happens if (or more likely, when) I misspell a route path, or decided to change a route path? Because we use hardcoded strings, Typescript is unable to let us know that the value we’ve supplied to the routerLink directive or the navigateByUrl function is an invalid route. Sure, we can use find all and replace in our code editor when we change routes, but relying on others or our future self to remember to do that is not a great idea.

An Initial Solution

We can move all the hardcoded strings from our routes object into a separate class called AppRoutesNames. We’ll make each route path a static read-only property, so we can access it without needing to initialize an instance of the class.

export class AppRouteNames {
  public static readonly FIRST_COMPONENT = 'first-component';

  public static readonly SECOND_COMPONENT = 'second-component';

  public static readonly THIRD_COMPONENT_WITH_CHILDREN =
    'third-component-with-children';

  public static readonly FIRST_CHILD_COMPONENT = 'first-child-component';

  public static readonly SECOND_CHILD_COMPONENT = 'second-child-component';

  public static readonly FALLBACK = '**';
}

Then we’ll remove the hardcoded strings inside our route object, and replace them with references to the route paths in our AppRouteNames class.

import { AppRouteNames } from './app-route-names';

const routes: Routes = [
  { path: AppRouteNames.FIRST_COMPONENT, component: FirstComponent },
  { path: AppRouteNames.SECOND_COMPONENT, component: SecondComponent },
  {
    path: AppRouteNames.THIRD_COMPONENT_WITH_CHILDREN,
    component: ThirdComponent,
    children: [
      { path: AppRouteNames.FIRST_CHILD_COMPONENT, component: FirstChildComponent },
      { path: AppRouteNames.SECOND_CHILD_COMPONENT, component: SecondChildComponent },
    ]
   },
  { path: AppRouteNames.FALLBACK, component: FallbackComponent },
];

Afterward, we can import the AppRouteNames class into our component, and use a route path as a value for the navigateByUrl function.

import { AppRouteNames } from './app-route-names';

// ...

export class AppComponent {
  // ...

  constructor(private router: Router) {
    this.router.navigateByUrl(AppRouteNames.FIRST_COMPONENT);
  }
  
  // ...
}

This is great! No more duplicated, hardcoded strings strung across our codebase. However, this solution does have a few issues. If we want to use the static route path properties inside the HTML of our components, we’ll need to add the AppRouteNames class as a protected property inside our component:

import { AppRouteNames } from './app-route-names';

// ...

export class AppComponent {
  protected AppRouteNames = AppRouteNames; 

  // ...

  constructor(private router: Router) {
    this.router.navigateByUrl(AppRouteNames.FIRST_COMPONENT);
  }
  
  // ...
}

Then we can use the AppRouteNames class inside our template:

<a [routerLink]="AppRouteNames.FIRST_COMPONENT">Link to first Component</a>

This works, but adding the AppRouteNames class as a property to every component where we want to use the route paths in a template is going to get very tedious.

Providing the AppRouteNames Class

We can eliminate some of the tediousness of creating the AppRouteNames class property by adding the AppRouteNames class as a provider. First, we’ll remove the static keyword from all of the properties in the AppRouteNames class:

export class AppRouteNames {
  public readonly FIRST_COMPONENT = 'first-component';

  public readonly SECOND_COMPONENT = 'second-component';

  public readonly THIRD_COMPONENT_WITH_CHILDREN =
    'third-component-with-children';

  public readonly FIRST_CHILD_COMPONENT = 'first-child-component';

  public readonly SECOND_CHILD_COMPONENT = 'second-child-component';

  public readonly FALLBACK = '**';
}

Next, we’ll add a new provider to the providers array inside our AppModule for the AppRouteNames class:

@NgModule({
  // ...
  providers: [
    // ... Other providers
    { provide: AppRouteNames, useClass: AppRouteNames },
  ],
  // ...
})

Now that we’ve removed the static keyword from all our route path properties in the AppRouteNames class, we’ll also need to change the way we provide routes to the RouterModule. Rather than passing in the constant Routes variable, we’ll pass in an empty array:

@NgModule({
  // ...
  imports: [
    // ... Other imports
    RouterModule.forRoot([]),
  ],
  // ...
})

Then we’ll change the constant Routes variable to a constant function called buildRoutes that takes in our AppRouteNames class, and uses that parameter to access the route paths in our route config:

const buildRoutes = (appRouteNames: AppRouteNames): Routes => [
  { path: appRouteNames.FIRST_COMPONENT, component: FirstComponent },
  { path: appRouteNames.SECOND_COMPONENT, component: SecondComponent },
  {
    path: appRouteNames.THIRD_COMPONENT_WITH_CHILDREN,
    component: ThirdComponent,
    children: [
      { path: appRouteNames.FIRST_CHILD_COMPONENT, component: FirstChildComponent },
      { path: appRouteNames.SECOND_CHILD_COMPONENT, component: SecondChildComponent },
    ]
   },
  { path: appRouteNames.FALLBACK, component: FallbackComponent },
];

To provide this route config to the RouterModule, we’ll add another provider below the one for the AppRouteNames in our AppModule. We’ll use the injection token ROUTES imported from @angular/router as the thing we’re providing, use a factory and our buildRoutes function to return the route config, set multi to true, and make sure to specify the AppRouteNames class as a dependency:

@NgModule({
  // ...
  providers: [
    // ... Other providers
    {
      provide: ROUTES,
      useFactory: (appRouteNames: AppRouteNames) => {
        return buildRoutes(appRouteNames);
      },
      multi: true,
      deps: [AppRouteNames]
    }
  ],
  // ...
})

As a result, we can now inject the AppRouteNames class as a protected member inside our component and use its class properties in a much more conventional way. Here’s what that looks like in the component class:

export class AppComponent {
  constructor(
    private router: Router,
    protected appRouteNames: AppRouteNames
  ) {
    this.router.navigateByUrl(appRouteNames.FIRST_COMPONENT);
  }
}

As well as how it gets used in the template now:

<a [routerLink]="appRouteNames.FIRST_COMPONENT">Link to first Component</a>

No more need to add the AppRouteNames class as a property of the component. Pretty slick!

But wait… There’s More (Issues)!

Remember how I said our initial solution had a few issues? Well, here’s what happens if we try to navigate to the FIRST_CHILD_COMPONENT route that’s nested underneath the THIRD_COMPONENT_WITH_CHILDREN route in our route config using just the FIRST_CHILD_COMPONENT property.

Fallback component pageFallback component page

Rather than navigating to our FirstChildComponent, we instead get redirected to our fallback route, an indication that we’ve passed our router an invalid route path. If we go back to our AppRouteNames class and take a look at the FIRST_CHILD_COMPONENT property, we can see that the value is just 'first-child-component'. While this setup may have worked for any of our routes on the first level of our route config, whenever we need to navigate to a route nested underneath another route, we need to include both the parent(s) route path and the route path for the component we are trying to navigate to. We can temporarily solve this issue by joining the THIRD_COMPONENT_WITH_CHILDREN and FIRST_CHILD_COMPONENT route paths with a slash:

<a [routerLink]="appRouteNames.THIRD_COMPONENT_WITH_CHILDREN + '/' + appRouteNames.FIRST_CHILD_COMPONENT">Link to first child Component</a>

Now, this link takes us to the FirstChildComponent page successfully:

FirstChildComponent page nested underneath the ThirdComponentWithChildren pageFirstChildComponent page nested underneath the ThirdComponentWithChildren page

But my goodness, that solution is ugly. What if we have more than one nested level in the route config? We’ll just have to keep on adding the parent paths, along with the slashes between them. Repeating that logic everywhere we want to use a nested route violates the DRY principle, and will get old fast.

Improving Our AppRouteNames Class

While not all of the route paths in our class are nested, it’s better if the structure for all our routes is the same. The first thing we’ll do is rename all the route paths in our AppRouteNames class by prefixing them with RELATIVE_. The fallback route path won’t be used anywhere except in our route config, so we can leave that one alone.

export class AppRouteNames {
  public readonly RELATIVE_FIRST_COMPONENT = 'first-component';

  public readonly RELATIVE_SECOND_COMPONENT = 'second-component';

  public readonly RELATIVE_THIRD_COMPONENT_WITH_CHILDREN =
    'third-component-with-children';

  public readonly RELATIVE_FIRST_CHILD_COMPONENT = 'first-child-component';

  public readonly RELATIVE_SECOND_CHILD_COMPONENT = 'second-child-component';

  public readonly FALLBACK = '**';
}

These route paths prefixed with RELATIVE_ are the route paths we’ll use in our route config that we pass to the RouterModule inside our AppModule. We’ll need to change our route config to use these new relative route paths.

const buildRoutes = (appRouteNames: AppRouteNames): Routes => [
  { path: appRouteNames.RELATIVE_FIRST_COMPONENT, component: FirstComponent },
  { path: appRouteNames.RELATIVE_SECOND_COMPONENT, component: SecondComponent },
  {
    path: appRouteNames.RELATIVE_THIRD_COMPONENT_WITH_CHILDREN,
    component: ThirdComponent,
    children: [
      { path: appRouteNames.RELATIVE_FIRST_CHILD_COMPONENT, component: FirstChildComponent },
      { path: appRouteNames.RELATIVE_SECOND_CHILD_COMPONENT, component: SecondChildComponent },
    ]
   },
  { path: appRouteNames.FALLBACK, component: FallbackComponent },
];

For each route path intended for use outside the route config, we’ll create another property with the same name, sans the RELATIVE_ prefix. This property will either be the relative route path, or for a nested component, a route path built from a series of relative paths.

export class AppRouteNames {
  public readonly RELATIVE_FIRST_COMPONENT = 'first-component';
  public readonly FIRST_COMPONENT = this.RELATIVE_FIRST_COMPONENT;

  public readonly RELATIVE_SECOND_COMPONENT = 'second-component';
  public readonly SECOND_COMPONENT = this.RELATIVE_SECOND_COMPONENT;

  public readonly RELATIVE_THIRD_COMPONENT_WITH_CHILDREN =
    'third-component-with-children';
  public readonly THIRD_COMPONENT_WITH_CHILDREN = this.RELATIVE_THIRD_COMPONENT_WITH_CHILDREN;

  public readonly RELATIVE_FIRST_CHILD_COMPONENT = 'first-child-component';
  public readonly FIRST_CHILD_COMPONENT = this.RELATIVE_THIRD_COMPONENT_WITH_CHILDREN + '/' + this.RELATIVE_FIRST_CHILD_COMPONENT;

  public readonly RELATIVE_SECOND_CHILD_COMPONENT = 'second-child-component';
  public readonly SECOND_CHILD_COMPONENT = this.RELATIVE_THIRD_COMPONENT_WITH_CHILDREN + '/' + this.RELATIVE_SECOND_CHILD_COMPONENT;

  public readonly FALLBACK = '**';
}

For our nested route paths, we can enhance this further by adding a private helper function to our AppRouteNames class that takes in the URL segments and joins them together with slashes, so we don’t have to remember to do it ourselves.

export class AppRouteNames {
  // ... Other routes

  public readonly RELATIVE_FIRST_CHILD_COMPONENT = 'first-child-component';
  public readonly FIRST_CHILD_COMPONENT = this.buildURL(this.RELATIVE_THIRD_COMPONENT_WITH_CHILDREN, this.RELATIVE_FIRST_CHILD_COMPONENT);

  public readonly RELATIVE_SECOND_CHILD_COMPONENT = 'second-child-component';
  public readonly SECOND_CHILD_COMPONENT = this.buildURL(this.RELATIVE_THIRD_COMPONENT_WITH_CHILDREN, this.RELATIVE_SECOND_CHILD_COMPONENT);

  // ... Other routes

  private buildURL(...parts: string[]): string {
    return parts.filter(Boolean).join('/');
  }
}

Then we can go back to the place where we originally just use the FIRST_CHILD_COMPONENT route path, and change it back to just the FIRST_CHILD_COMPONENT.

<a [routerLink]="appRouteNames.FIRST_CHILD_COMPONENT">Link to first child component</a>

Now, this takes us to our nested component correctly, with no string concatenation inside our template!

FirstChildComponent page nested underneath the ThirdComponentWithChildren pageFirstChildComponent page nested underneath the ThirdComponentWithChildren page

While this did fix our issue with nested routes, it doubled the number of properties on AppRouteNames class, which can add up in a larger application. This solution also runs into issues when handling route paths that contain route parameters.

Handling Route Parameters

So far, we haven’t used any routes that take advantage of Angular’s route parameters. We can start by adding the relative route path to our AppRouteNames class.

export class AppRouteNames {

  // ... Other routes

  public readonly RELATIVE_FOURTH_COMPONENT_WITH_ROUTE_PARAMETER = 'fourth-component/:id';

  // ...
}

Then we can add the route to our route config in the buildRoutes function.

const buildRoutes = (appRouteNames: AppRouteNames): Routes => [

  // ... Other routes
  
  { path: appRouteNames.RELATIVE_FOURTH_COMPONENT_WITH_ROUTE_PARAMETER, component: FourthWithRouteParameterComponent },
  
  // ...
  
];

In this case, we won’t be able to use another class property for the full route path, because we’ll want to be able to dynamically generate the :id portion. Instead, we’ll use a function that takes in the id as a string. We can still use the buildURL helper function to build the URL, but because of the :id section of the relative route path, we won’t be able to use it inside of the function, and will instead have to duplicate the 'fourth-component' portion of the route path.

export class AppRouteNames {
  
  // ... Other routes

  public viewFourthComponent(id: string): string {
    return this.buildURL('fourth-component', id);
  }

  // ...
}

Now we can use this route path in our template:

<a [routerLink]="appRouteNames.viewFourthComponent('1')">Link to fourth component with id of 1</a>

Navigating us to that component with an id of 1:

FourthComponent with an id of 1FourthComponent with an id of 1

But, this brings us right back to our initial problem of duplicated strings, and while they’re not strung all over the codebase, it feels like we shouldn’t need to do duplicate strings at this point. What if there was a way to access our route paths as a typed object and avoid duplicated strings, without having to specify a bunch of route paths and builder functions in a separate class, apart from our route config?

ngx-advanced-router

This question is what led me to build ngx-advanced-router. It functions as a replacement for Angular’s default RouterModule, and it allows you to specify your routing config as an injectable service and access your route paths quickly and easily. We can start by installing that package in our Angular project:

npm install --save ngx-advanced-router

Next, we’ll need to create a new service that extends AdvancedRouteService. Extending this class requires that we implement one abstract property: routesConfig. This property is where we’ll put a similar route config that we returned in our buildRoutes function earlier. However, rather than an array of route objects, we’ll have an object with keys and the route objects as the values. Here’s what that would look like when we convert the route config from our buildRoutes function:

// ... Other imports
import { AdvancedRouteService } from 'ngx-advanced-router';

@Injectable({
  providedIn: 'root'
})
export class AppRouteService extends AdvancedRouteService {
  public readonly routesConfig = {
    first: { path: 'first-component', component: FirstComponent },
    second: { path: 'second-component', component: SecondComponent },
    thirdWithChildren: {
      path: 'third-component-with-children',
      component: ThirdComponent,
      children: {
        firstChild: { path: 'first-child-component', component: FirstChildComponent },
        secondChild: { path: 'second-child-component', component: SecondChildComponent },
      }
    },
    fourthWithRouteParameter: {
      path: (id: string) => {
        return `fourth-component/${id}`;
      },
      component: FourthWithRouteParameterComponent
    },
    fallback: { path: '**', component: FallbackComponent },
  }
}

One thing to notice is that we moved the actual route path strings back into the route config, just like how we had them at the very beginning of this article. Another thing to notice is that we not only can pass in a string for the path, but also a function, as shown on the path for the fourthWithRouteParameter route.

To use this service, we’ll go back to our AppModule, and remove both the AppRouteNames and ROUTES providers from our providers array, as well as the RouterModule from our imports array. We’ll instead add the AdvancedRouterModule with its forRoot function to the imports array, passing in our newly created route service:

@NgModule({
  imports: [
    // ... Other imports
    AdvancedRouterModule.forRoot(AppRouteService),
  ],
  providers: [],
})
export class AppModule {}

In our AppComponent where we were injecting the AppRouteNames class, we’ll instead import the AppRouteService. To use this service to access our route paths, we’ll use the service’s routes property. Accessing this property gives us a nice set of auto-complete options, based on the route config we set up in the service earlier.

If we’re wanting to navigate to the ‘first’ route, we can use the path property, which returns the full route path.

this.router.navigateByUrl(appRouteService.routes.first.path);

For routes with children, they’ll have a children property, in addition to the path property, which also gives us a nice auto-complete list.

Here’s what the full code for accessing that child route path looks like:

this.router.navigateByUrl(appRouteService.routes.thirdWithChildren.children.firstChild.path);

For dynamic routes involving route parameters, like the fourthWithRouteParameter route, the routes object gives us a function with the same parameters as the function we set up in our route config. We can call this function, and then access the path property to get the full route path.

<a [routerLink]="appRouteService.routes.fourthWithRouteParameter('1').path">Link to fourth component with id of 1</a>

That’s it! This solution solves all the issues we were having:

  1. No duplicated strings Now strings are used once in the route config, and the routes property is used to access route paths for everything else.

  2. No extra component properties Our route service uses Angular’s dependency injection, so no need to add a property to our component just to access our route paths.

  3. No impromptu route path building The route service takes care of building our route paths for us, whether they’re on the root level or 5 levels deep.

  4. No relative route paths ngx-advanced-router takes care of providing the routes to Angular’s RouterModule in the format it’s looking for and provides the absolute route paths to you.

  5. No extra route path class Declare your route setup once in the route service, use it in the AdvancedRouterModule.forRoot() function, and you’re done!

Let me know what you think in the comments! I’m open to suggestions to make this package better.

TwitterFacebookGithubLinkedIn

© 2024 Dane Vanderbilt