Dynamic Angular Configuration

Angular apps traditionally rely on static configuration through environment files, necessitating separate builds for each environment ‐ a cumbersome process in practical scenarios. In this blog post, we describe an alternative, where configuration is loaded dynamically at runtime, streamlining development and enhancing flexibility. As an additional benefit, we will validate the configuration against a schema and add type-safety to it.

Chameleon with Angular Logo
Original photo by Pierre Bamin on Unsplash

Table Of Contents

What is the Problem With Angular Environments?

Angular environments are essentially build configurations. Their most important feature is the ability to swap files during the build process depending on the configuration chosen. In almost all cases this will be a file src/environments/environments.ts, see Building and serving Angular apps for more information!

This approach may be okay, when you only have two environments, typically a development versions running locally and a production version that gets deployed to some web server. But it will quickly become unwieldy, when you have more deployment stages like "testing", "integration", and "production". If your application is internationalized and uses compile time translations (like @angular/localize does by default), the situation even gets worse because the number of applications to be built will be the number of stages multiplied with the number of supported locales.

Another problem is that all the configuration must be contained in the source code. This is not a security problem because the source code of the application must not contain any confident information because this can be easily found with the JavaScript console. But the necessity to re-build the application, whenever the configuration changes can make the deployment cumbersome.

Dynamic Configuration

It is much more flexible to load the configuration at runtime. In order to make that work, the bootstrapping process of the Angular application has to be modified. It will look roughly like this:

  1. Determine the location of the config file depending on the local environment like hostname or ip address or web server configuration.
  2. Load the configuration with the Fetch API.
  3. Inject the configuration.
  4. Do the regular Angular bootstrapping with bootstrapApplication from the selected platform.

Alternatively, you could modify the index.html file of your application to include the configuration via a script tag. We will not further discuss this approach because it does not fit well if you want to run the application inside of a container. For that use case, you shift the problem from the build step to the deployment step because you have to build multiple images for each stage. If this is not an issue for you, you may follow that strategy. It should be straightforward to implement.

Implementation

In order to try out our approach create a new Angular project in a directory of your choice:

$ npx ng new --strict dynamic-angular-configuration

This will create a standalone Angular application.

If you prefer, you can also clone the accompanying git repository at https://github.com/gflohr/dynamic-angular-configuration. Its commit log follows the steps described here more or less.

Change the Start Page

Change src/app/components/app.component.html to look like this:

<h1>Dynamic Angular Configuration</h1>

<router-outlet />

Start the application with npm run watch and point your browser to http://localhost:4200/. If you are lucky, you see the line "Dynamic Angular Configuration". Our goal will be to add a second paragraph with the environment description.

Create Configuration Files

Next we want to create different configurations. Create a file src/assets/config.dev.json:

{
    "production": false,
    "description": "This is the dev environment!",
}

And one more src/assets/config.prod.json:

{
    "production": true,
    "description": "This is the prod environment!",
}

Create Configuration Type

It is a good idea to have type-safety for the configuration. This avoids typos and also aids in development because it allows auto-completion. A simple way to do this would look like this:

type Configuration = {
    production: boolean,
    description: string,
};

But we are loading the configuration with an Ajax request and it makes sense to validate it against a schema before using it. The problem is that you then have to then maintain, both the schema and the Typescript type, and have to keep the two in sync.

It would be cool if we could generate the schema from the type definition, and even cooler to do it the other way round because schemas are usually a lot stricter than TypeScript types. Fortunately, such an option really exists in form of the software valibot.

First we add valibot to our project:

$ npm add --save valibot

There is no need to install types because valibot is written in TypeScript.

Now create a file src/app/configuration.ts:

import * as v from 'valibot';
import { InjectionToken } from '@angular/core';

export const ConfigurationSchema = v.object({
    production: v.boolean(),
    description: v.string([v.minLength(5)]),
    email: v.optional(v.string([v.email()]), 'info@example.com'),
    answers: v.optional(
        v.object({
            all: v.optional(v.number([v.minValue(1)]), 42),
        }),
        {},
    ),
});

export type Configuration = v.Input<typeof ConfigurationSchema>;

export const CONFIGURATION = new InjectionToken<Configuration>('Configuration');

The interesting part starts at line 4 where we define the configuration schema. Since our configuration is a nested object we also need a nested schema.

You define a schema with the help of schema functions like object(), boolean(), string(), or number(). You can optionally use pipelines like email(), minLength(), or minValue(). These allow for stricter checks.

For illustration purposes, I have added two optional fields email and answers. The optional schema function takes an optional second argument with a default value. See the valibot documentation for more information!

In line 16, the TypeScript type definition is inferred from the schema.

Finally, in line 18, we create an injection token for the configuration so that we can inject it as a dependency.

The Angular Bootstrap Process

Before we we continue, we should get a thorough understanding of the Angular bootstrapping process because we have to modify it. The application entry point is src/main.ts. By default, it looks like this:

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err));

The function bootstrapApplication() takes two arguments: The root component and a configuration object. The latter is imported from src/app/app.config.ts:

import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
    providers: [provideRouter(routes)],
};

As you can see, its sole purpose is to provide dependencies for the dependency injection system. That means that we have to add a provider for the configuration that we load exactly here. The problem is that we do not have the configuration at this point because it is loaded dynamically with an Ajax request.

Make the Application Configuration Dynamic

Exporting a constant object will therefore no longer work. We have to use an asynchronous function instead that loads the configuration via HTTP and then provides the configuration object. We therefore change src/app/app.config.ts like this:

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import * as v from 'valibot';

import { routes } from './app.routes';
import {
    CONFIGURATION,
    Configuration,
    ConfigurationSchema,
} from './configuration';

export const createAppConfig = async (): Promise<ApplicationConfig> => {
    const configurationPath = locateConfiguration(location);
    const response = await fetch(configurationPath);
    const json = await response.json();
    const configuration = parseConfiguration(json);

    return {
        providers: [
            { provide: CONFIGURATION, useValue: configuration },
            provideRouter(routes),
        ],
    };
};

const locateConfiguration = (location: Location): string => {
    if (location.hostname === 'localhost') {
        return './assets/config.dev.json';
    } else {
        return './assets/config.prod.json';
    }
};

const parseConfiguration = (configuration: unknown): Configuration => {
    try {
        return v.parse(ConfigurationSchema, configuration);
    } catch (e) {
        const v = e as v.ValiError;
        const issues = v.issues;
        console.error('Configuration error(s):');
        for (const issue of issues) {
            const path =
                issue.path?.map((segment: any) => segment.key).join('.') ??
                '[path not set]';
            console.error(`  error: ${path}: ${issue.message}`);
        }
        throw new Error('Application not started!');
    }
};

In line 13, we first determine the path to the configuration file. The function locateConfiguration() defined in line 26 uses a very simple approach. If the hostname is "localhost", it loads the development configuration. Otherwise it loads the production configuration. Your mileage may vary.

In lines 14-16, the configuration is retrieved via HTTP and parsed by the function parseConfiguration() defined in line 34. This function uses the valibot parser that throws an exception in case of validation errors. Try that out by modifying the configuration files under /assets.

Finally, in line 20, a provider for the configuration object is added and returned.

Change the Application Entry Code

The application entry point src/main.ts no longer compiles. We have to modify it as follows:

import { bootstrapApplication } from '@angular/platform-browser';
import { createAppConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

createAppConfig()
    .then(appConfig => bootstrapApplication(AppComponent, appConfig))
    .catch(err => console.error(err));

This should be straightforward. Instead of using a static ApplicationConfig object, we now must call a function. By the way, it is not possible to use await here to load appConfig because this is not allowed in top-level files. So we must make do with conventional promise chaining.

Render Configuration Values

The application now compiles and runs again. But does it also work?

We change the main component src/app/app.component.ts as follows:

import { Component, Inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { CONFIGURATION, Configuration } from './configuration';

@Component({
    selector: 'app-root',
    standalone: true,
    imports: [RouterOutlet],
    templateUrl: './app.component.html',
    styleUrl: './app.component.css',
})
export class AppComponent {
    description: string;
    answer: number;

    constructor(
        @Inject(CONFIGURATION)
        private configuration: Configuration,
    ) {
        this.description = this.configuration.description;
        this.answer = this.configuration.answers?.all as number;
    }
}

The configuration gets injected and two public properties description and answer are created from it. We now can render them by modifying the component template src/app/app.component.html:

<h1>Dynamic Angular Configuration</h1>
<p id="description">{{ description }}</p>
<p id="answer">
    The answer to the ultimate question of life, the universe, and everything is
    {{ answer }}.
</p>
<router-outlet />

Point your browser to http://localhost:4200/ and you should now see the description saying that it is the development environment plus the answer to the ultimate question of life, the universe, and everything.

You can also stop the server and change the host to 127.0.0.1 with the command npx ng serve --host 127.0.0.1. If you open http://127.0.0.1:4200/, you will see that the application now considers itself to be running in the prod environment because our code explicitly checks that the hostname is localhost.

Testing

Everything is up and running, and you could actually go dedicate your precious time to more challenging tasks. But now that the bootstrapping code contains business logic, a little testing would not hurt, too.

We will not go into great details but just describe how the test can be performed. For more details, please look at the source code at https://github.com/gflohr/dynamic-angular-configuration/blob/main/src/app/app.config.spec.ts.

Since src/app/app.config.ts makes an HTTP request we have to mock the global fetch() method. We define the mock as a spy with fetchSpy = spyOn(window, 'fetch') so that we can check that fetch() was invoked with the configuration path that we expect.

We also want to test that the validation of the configuration actually works by checking that invalid configurations are rejected and produce the expected number of errors. That requires a little change to src/app/app.config.ts because the function parseConfiguration() was not exported before.

Finally, we also test that default values work.

Conclusion

Loading the configuration of an Angular application over HTTP has a lot of advantages. It can reduce the number of builds drastically and also adds flexibility to your deployment. If you want to follow this approach, feel free to use the accompanying GitHub repository dynamic-angular-configuration as a starter project.

If you want to give feedback, leave a comment below or create an issue on GitHub!

Leave a comment
This website uses cookies and similar technologies to provide certain features, enhance the user experience and deliver content that is relevant to your interests. Depending on their purpose, analysis and marketing cookies may be used in addition to technically necessary cookies. By clicking on "Agree and continue", you declare your consent to the use of the aforementioned cookies. Here you can make detailed settings or revoke your consent (in part if necessary) with effect for the future. For further information, please refer to our Privacy Policy.