Back
Blog Post

Ditch process.env, use a typed config

Cully Larson
Cully LarsonMonday, August 14, 2023
Ditch process.env, use a typed config

You have some values in your application that need to change depending on what environment or situation the app is used in. You decide to pass that configuration into your app using environment variables and access them using process.env. Great decision! Your app is portable, your configuration isn’t hard-coded, and you aren’t exposing secrets in your codebase.

But now you have a bunch of process.env references all over the place. Every dev on the team uses them in a different way. It’s hard to track what is even required to configure your app, let alone what those configuration values actually need to be. It’s the wild west.

There’s a better way! Centralize your configuration in one place and never look at a process.env again. This article will go over a configuration best-practice that has worked well at Echobind on a number of our projects.

Centralize Configuration

Rather than having configuration spread all over your application, put it in one place. A simple version of that might look like:

// config.ts export const config = { stage: process.env.NODE_ENV, app: { port: process.env.APP_PORT, }, db: { host: process.env.DB_HOST, username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, name: process.env.DB_NAME, port: process.env.DB_PORT, }, cache: { lifetimeMs: process.env.CACHE_LIFETIME_MS, }, features: { darkMode: { enabled: process.env.FEATURE_DARK_MODE_ENABLED, }, }, }; export type Config = typeof config;

All your process.env access is in one file. It’s easy to see what you need to configure you application. And you get type safety for free!

Now whenever you want to use a config value, you can do:

import { config } from "@/config"; if(config.features.darkMode.enabled) { // get dark }

This is good, but it could be better. One problem with process.env values is that they are either string or undefined. You’ll constantly have to check whether a value is defined before using it. And what if you want a number or a boolean? What if you want to provide a default value if a value isn’t set?

Coerce Configuration

To get our config values into a more useful form, let’s coerce them using some helpful functions:

export function envToString( value: string | undefined, defaultValue?: string ): string | undefined { return value === undefined || value === "" ? defaultValue : value; } export function envToNumber( value: string | undefined, defaultValue?: number ): number | undefined { return value === undefined || value === "" ? defaultValue : Number(value); } export function envToBoolean( value: string | undefined, defaultValue?: boolean ): boolean | undefined { if (value === undefined || value === "") { return defaultValue; } // Explicitly test for true instead of false because we don't want to turn // something on by accident. return ["1", "true"].includes(value.trim().toLowerCase()) ? true : false; }

Now we can use those functions to convert our config values into more usable types:

export const config = { stage: envToString(process.env.NODE_ENV), app: { port: envToNumber(process.env.APP_PORT, 3000), }, db: { host: envToString(process.env.DB_HOST), username: envToString(process.env.DB_USERNAME), password: envToString(process.env.DB_PASSWORD), name: envToString(process.env.DB_NAME), port: envToNumber(process.env.DB_PORT), }, cache: { lifetimeMs: envToNumber(process.env.CACHE_LIFETIME_MS, 3600000), // 3600000ms = 1h }, features: { darkMode: { enabled: envToBoolean(process.env.FEATURE_DARK_MODE_ENABLED), }, }, };

Now our configuration values can be strings, booleans, or numbers. And we can provide default values if we want to set a field to something if no value is set in process.env. However, we have a couple more problems. First, our values could also be undefined. We could fix this with TS so that if a defaultValue is provided, the return value won’t be undefined.

Even with that change, we will still have a second problem: There is no validation of our config. If we’re missing a required value, a value is out of range, etc. our app will continue along with confidence.

Validate Configuration

So let’s validate it! Zod is a great tool for this. We’ll define a schema to make sure our configuration is correct:

import { z } from "zod"; const configSchema = z.object({ stage: z.string().nonempty(), app: z.object({ port: z.number().positive(), }), db: z.object({ host: z.string().nonempty(), username: z.string().nonempty(), password: z.string().nonempty(), name: z.string().nonempty(), port: z.number().positive(), }), cache: z.object({ lifetimeMs: z.number().positive(), }), features: z.object({ darkMode: z.object({ enabled: z.boolean(), }), }), }); export type Config = z.infer<typeof configSchema>; export const config = configSchema.parse({ stage: process.env.NODE_ENV, app: { port: envToNumber(process.env.APP_PORT, 3000), }, db: { host: envToString(process.env.DB_HOST), username: envToString(process.env.DB_USERNAME), password: envToString(process.env.DB_PASSWORD), name: envToString(process.env.DB_NAME), port: envToNumber(process.env.DB_PORT), }, cache: { lifetimeMs: envToNumber(process.env.CACHE_LIFETIME_MS, 3600000), // 3600000ms = 1h }, features: { darkMode: { enabled: envToBoolean(process.env.FEATURE_DARK_MODE_ENABLED), }, }, });

If our config is invalid, the schema check will throw an exception. If our application continues running past the check, we know our config is valid.

That checks all our boxes. Configuration is centralized, typed, and valid. Though there are still a few improvements we can make.

How to handle “stage”

In our config, “stage” would be something like production, staging, development, test, etc. It basically tells us what environment our application is running in. This can be helpful if we want to do different things in our app based on the environment.

export const stages = ["production", "staging", "development", "test"] as const; export type Stage = (typeof stages)[number]; const configSchema = z.object({ stage: z.enum(stages), // ... }); export const config = configSchema.parse({ stage: envToString(process.env.NODE_ENV, "development"), // ... });

And some other helpful functions around stage:

function notEmpty<TValue>(value: TValue | null | undefined): value is TValue { return value !== null && value !== undefined; } function getStage(stages: Stage[]): Stage { if (!stages.length) return 'development'; for (const stage of stages) { // if any of the provided stages is not production, // assume we aren't in production if (stage !== 'production') { return stage; } } return stages[0]; } function isStage(potentialStage: string): potentialStage is Stage { return stages.includes(potentialStage as Stage); } const stage = getStage( [process.env.NODE_ENV, process.env.NEXT_PUBLIC_APP_ENV].filter(notEmpty).filter(isStage) );

Fail earlier

One issue with the current configuration is that Typescript won’t tell us if it knows we’ve passed a bad value to our config. We won’t find that out until runtime. We can resolve this using satisfies:

export const config = configSchema.parse({ stage: envToString(process.env.NODE_ENV, "development"), app: { port: envToNumber(process.env.APP_PORT, 3000), }, db: { host: envToString(process.env.DB_HOST), username: envToString(process.env.DB_USERNAME), password: envToString(process.env.DB_PASSWORD), name: envToString(process.env.DB_NAME), port: envToNumber(process.env.DB_PORT), }, cache: { lifetimeMs: envToNumber(process.env.CACHE_LIFETIME_MS), }, features: { darkMode: { enabled: envToBoolean(process.env.FEATURE_DARK_MODE_ENABLED), }, }, } satisfies Config);

Notice the satisfies Config at the end of our config object. This will tell TS that the object needs to be of type Config. TS will then warn us if it knows one of our values isn’t correct. The downside here is that the return of the envTo functions can be undefined, so TS will complain about that. We could leave out the satisfies or always provide default values and update the envTo functions to not return undefined if a default value is provided:

// overload envToString so we know what the return value will be if a default // value is provided function envToString(value: string | undefined, defaultValue: string): string; function envToString( value: string | undefined, defaultValue?: undefined ): string | undefined; function envToString( value: string | undefined, defaultValue?: string ): string | undefined { return value === undefined || value === "" ? defaultValue : value; } // overload envToNumber so we know what the return value will be if a default // value is provided function envToNumber(value: string | undefined, defaultValue: number): number; function envToNumber( value: string | undefined, defaultValue?: undefined ): number | undefined; function envToNumber( value: string | undefined, defaultValue?: number ): number | undefined { return value === undefined || value === "" ? defaultValue : Number(value); } // overload envToBoolean so we know what the return value will be if a default // value is provided function envToBoolean( value: string | undefined, defaultValue: boolean ): boolean; function envToBoolean( value: string | undefined, defaultValue?: undefined ): boolean | undefined; function envToBoolean( value: string | undefined, defaultValue?: boolean ): boolean | undefined { if (value === undefined || value === "") { return defaultValue; } // Explicitly test for true instead of false because we don't want to turn // something on by accident. return ["1", "true"].includes(value.trim().toLowerCase()) ? true : false; }

Using these version of the envTo functions, if a default value is provided to e.g. envToBoolean, the return type will be boolean, not boolean | undefined. Forcing default values may not be the desired behavior if you want to initially allow undefined values to be set and then fail during the zod schema check.

Putting it all together

Leaving out the satisfies example above, this is the result:

import { z } from "zod"; function envToString( value: string | undefined, defaultValue?: string ): string | undefined { return value === undefined || value === "" ? defaultValue : value; } function envToNumber( value: string | undefined, defaultValue?: number ): number | undefined { return value === undefined || value === "" ? defaultValue : Number(value); } function envToBoolean( value: string | undefined, defaultValue?: boolean ): boolean | undefined { if (value === undefined || value === "") { return defaultValue; } // Explicitly test for true instead of false because we don't want to turn // something on by accident. return ["1", "true"].includes(value.trim().toLowerCase()) ? true : false; } const stages = ["production", "development", "test"] as const; type Stage = (typeof stages)[number]; const configSchema = z.object({ stage: z.enum(stages), app: z.object({ port: z.number().positive(), }), db: z.object({ host: z.string().nonempty(), username: z.string().nonempty(), password: z.string().nonempty(), name: z.string().nonempty(), port: z.number().positive(), }), cache: z.object({ lifetimeMs: z.number().positive(), }), features: z.object({ darkMode: z.object({ enabled: z.boolean(), }), }), }); export type Config = z.infer<typeof configSchema>; export const config = configSchema.parse({ stage: envToString(process.env.STAGE), app: { port: envToNumber(process.env.APP_PORT, 3000), }, db: { host: envToString(process.env.DB_HOST), username: envToString(process.env.DB_USERNAME), password: envToString(process.env.DB_PASSWORD), name: envToString(process.env.DB_NAME), port: envToNumber(process.env.DB_PORT), }, cache: { lifetimeMs: envToNumber(process.env.CACHE_LIFETIME_MS, 3600000), // 3600000ms = 1h }, features: { darkMode: { enabled: envToBoolean(process.env.FEATURE_DARK_MODE_ENABLED), }, }, });

Why not use zod for everything?

Zod does have the ability to coerce values. But it’s not ideal, especially for booleans. And it probably doesn’t handle undefined like you want it to.

We could implement the envTo function using something like preprocess, but it’s messy.

In the end, we’ve found the envTo functions to be a simpler, cleaner, more predictable, and easier-to-understand solution than going with zod for everything.

Conclusion

Why conclude? You don’t need me to tell you what you learned. In this article we went over the things you learned from reading it and we can rest assured you learned those things. Just copy/paste the “Putting it all together” code block.

Share this post

twitterfacebooklinkedin

Interested in working with us?

Give us some details about your project, and our team will be in touch with how we can help.

Get in Touch