look@me

Accessing environment variables in single-page applications

Although this blog post focuses on Angular, it may also be applicable to other frameworks.

It's May 2024 and Angular's official solution for using environment-specific variables in our apps is still based on file replacements during build.1 This is basically nothing different from storing configuration as constants in the code, which has been considered a bad practices since decades.2 But even worse, this solution requires a rebuild for different environments. And there is no limitation in what the environment files contain. From just a constant with static variables to highly complex logic, everything is allowed. This can result in totally different behaviors depending on the environment.

Nothing New

There are a couple of alternative approaches out there. Some store a JSON file with environment variables in the assets/ folder, which Angular doesn't modify during build.3 This file can then be modified during deployment by file replacement or variable substitution. Others provide a dedicated endpoint on the server, which is probably the only way to provide real environment variables.4

Type Safe Environment Variables

Whatever approach best fits our needs, the setup and usage can always be the same. Environment variables often contain crucial information, like the host URL of our back-end or settings for the authentication process. So it's reasonable to load the environment variables as soon as possible, and to load them in a type safe manner. If some are missing or in the wrong format, our application is probably not going to work at all.

Let's start by defining the variables we need.

type EnvironmentVariables {
	API_BASE_URL: string,
	ENABLE_FEATURE_A?: false
}

We write a type guard, so we can later easily check if the environment variables we receive have the correct format.

function isEnvironmentVariables(value: unknown): value is EnvironmentVariables {
    return 
        value !== null &&
        typeof value === 'object' &&
        'API_BASE_URL' in value &&
        typeof value.WEB_CLIENT_ID === 'string' &&
        'ENABLE_FEATURE_A' in value ?
		typeof value.ENABLE_FEATER_A === 'boolean' :
		true
}

As we can see, the type guard can become quite complex, even for just a view variables. I wrote a separate article about type safety when working with unknown data. It describes risks and pitfalls and an alternative, less error-prone approach.

Finally, we define an Angular injection token. This token will later allow every part of the application to access the environment variables via dependency injection.5

const ENVIRONMENT_VARIABLES = new InjectionToken<EnvironmentVariables>('ENVIRONMENT_VARIABLES');

Fail Fast or Take Off

Regardless of where we store our environment variables, we must load them somehow. And, as mentioned before, we want to perform this action as soon as possible. With the browser's native Fetch API, we can load the environment variables even before our application gets bootstrapped.

fetch('/assets/environment-variables.json')
    // πŸ‘† We could also fetch an API endpoint here.
    // πŸ‘† Or we can fetch a different file during local development.
    // πŸ‘† In Angular, this can be determined using the `isDevMode()` function.
    .then((response: Response) => response.json())
    .then((environmentVariables: unknown) => {
        if (!isEnvironmentVariables(environmentVariables)) {
            // Here we can decide what happens when something
            // is wrong with our environment variables.
        }

        // When everything is good to go, we start our app
        // and make the environment variables generally available.
        // With Angular standalone components, it looks like this:
        return bootstrapApplication(RootComponent, {
            providers: [
                {
                    provide: ENVIRONMENT_VARIABLES,
                    useValue: environmentVariables,
                },
            ],
        });
    })
    .catch((reason: unknown) => console.error(reason));

Variable Substitution

If we decided to go for the approach with a JSON file in the /assets folder, we may want to make use of variable substitution. The common envsubst program enables environment variable substitution in a standardized way. All variables of the form $VARIABLE or ${VARIABLE} get replaced with the corresponding value of the real, same-named environment variables. So a template to substitute our previous defined environment variables is just a JSON file with placeholders.

{
	"API_BASE_URL": "$API_BASE_URL",
	"ENABLE_FEATURE_A": "$ENABLE_FEATURE_A"
}

During deployment, we can then run envsubst to create our final JSON file with the actual environment variables.

envsubst < ./assets/environment-variables.template.json > ./assets/environment-variables.json

The same approach can be applied to the bootstrap routine of a container or a "serverless" function.

Local Development

Depending on which approach we choose, our application will not start locally at this point. If we expect the environment variables to be placed in the /assets folder, it's not there yet. Of course, we can just create the file and add values that work for local development. But if something goes wrong during deployment, these local settings can end up in production. To avoid this scenario, we can create a dedicated file and load it just locally. The content of the file is a valid JSON document without placeholders, but actual values.

{
	"API_BASE_URL": "https://localhost:6000",
	"ENABLE_FEATURE_A": true
}

Before we fetch the environment variables, we must decide which file to load. In Angular, we can use the isDevMode() function to make this decision.

const environmentVariablesFile = isDevMode()
    ? '/assets/environment-variables.local.json'
    : '/assets/environment-variables.json';

fetch(environmentVariablesFile)...

Conclusion

Angular's official solution for environment-specific variables comes with avoidable risks. There are a couple of alternative approaches out there, mainly varying in where the environment variables come from. The common thread of these approaches uses standard TypeScript and native browser APIs, and is therefore suitable for any kind of web application.

  1. Based on Angular's official documentation as of 2024-05-13.

  2. See the Twelve Factors and its background for just one example.

  3. Tim Deschryver: Build once deploy to multiple environments πŸš€

  4. Yannick Haas: Using a server’s environment variables in Angular

  5. Please refer to the official documentation for more information about Dependency Injection with Angular.

#angular #code_quality #type_safety #typescript