Writing applications that scale is hard.
To do so, most of the time we try to apply well-known standards and practices to better address the problems we might encounter.
One problem every single application ever written has is managing the application configuration. It could be basic things like the port your app should use, but it can go deeper, like the database host, API URLs, feature-specific settings, etc.
In this post, I want to dive deep into this problem and propose a solution for when you're writing JS/TS applications that will definitely help you better organize your application configs in a sane way.
The Config Problem
Say you're building an app and this app needs 2 configs (at least for now): the port and some db settings.
You could declare this where it's being used, but as a good practice, you're isolating this into a config.ts file:
const config = {
port: 3000,
db: {
host: "localhost",
port: 5432,
},
} as const;
Cool. Now, you have the settings isolated in a place where you and the people who will work on this project know how to change them.
But now we have a problem: while this works locally, we want to deploy our application and use different settings. For now, let's say we'll only have a "local" and "production" environment.
We'll need to expand our config.ts and start considering using an APP_ENV environment variable to determine where the application is running:
interface AppConfig {
port: number;
db: {
host: string;
port: number;
};
}
type Environment = "local" | "prod";
const localConfig: AppConfig = {
port: 3000,
db: {
host: "localhost",
port: 5432,
},
};
const productionConfig: AppConfig = {
port: 8080,
db: {
host: "db.example.com",
port: 5432,
},
};
const configMap: Record<Environment, AppConfig> = {
prod: productionConfig,
local: localConfig,
};
// @ts-expect-error - We know it can be undefined
export const config = configMap[process.env.APP_ENV];
if (!config) {
throw new Error(
`Configuration for environment "${process.env.APP_ENV}" not found.`,
);
}
At this point, the code started to get a bit more complex but is still manageable. That's because we only have two environments and our config is small, but what if we want to have more environments such as dev and/or staging?
Well, we'd need to add those values to our Environment type and implement the configuration.
To better organize the code, you could even split it into files, like:
./
└─ ...
└─ src/
└─ ...
└─ config/
├─ dev.ts
├─ index.ts
├─ local.ts
├─ prod.ts
├─ staging.ts
└─ type.ts
In each "environment config file", you'll declare the values for that environment and we'll end up with a config file like this:
import { devConfig } from "./dev";
import { localConfig } from "./local";
import { productionConfig } from "./prod";
import { stagingConfig } from "./staging";
import type { AppConfig, Environment } from "./types";
const configMap: Record<Environment, AppConfig> = {
prod: productionConfig,
local: localConfig,
dev: devConfig,
staging: stagingConfig,
};
// @ts-expect-error - We know it can be undefined
export const config = configMap[process.env.APP_ENV];
if (!config) {
throw new Error(
`Configuration for environment "${process.env.APP_ENV}" not found.`,
);
}
Much better to reason about and to organize, but we still have some flaws in this workflow, for example:
... What if, for all environments we have, the configuration is the same? Or, it's the same for all except for live?
Here, you could have a "base config" where you merge (if you have nested objects you must deep merge) the configuration:
// config/base.ts
import type { AppConfig } from "./types";
export const baseConfig: AppConfig = {
port: 3000,
db: {
host: "localhost",
port: 5432,
},
};
// config/dev.ts
import { baseConfig } from "./base";
import type { AppConfig } from "./types";
export const devConfig: AppConfig = deepMerge({}, baseConfig, {
port: 8080,
db: {
host: "db-dev.example.com",
},
});
This will generate a config object like:
{
"port": 8080,
"db": {
"host": "db-dev.example.com",
"port": 5432, // port is from base
},
}
... What if I want to have runtime validation for my config?
In this case, you can use zod or any schema validation library, define your app config schema and validate it. By doing this you ensure type-safety at both compile and runtime:
// config/types.ts
import { z } from "zod/v4";
export const AppConfig = z.object({
port: z.number(),
db: z.object({
host: z.string(),
port: z.number(),
}),
});
export type AppConfig = z.Infer<typeof AppConfig>;
export const Environment = z.enum(["dev", "local", "prod", "staging"]);
export type Environment = z.Infer<typeof Environment>;
// config/index.ts
import { devConfig } from "./dev";
import { localConfig } from "./local";
import { productionConfig } from "./prod";
import { stagingConfig } from "./staging";
import { AppConfig, Environment } from "./types";
const configMap: Record<Environment, AppConfig> = {
prod: productionConfig,
local: localConfig,
dev: devConfig,
staging: stagingConfig,
};
const env = Environment.parse(process.env.APP_ENV);
export const config = AppConfig.parse(configMap[env]);
... What if I want to use environment variables?
For this, you'd probably have to do something like this:
import { z } from "zod/v4";
import type { AppConfig } from "./types";
const port = z.coerce.number().default(3000).parse(process.env.PORT);
export const localConfig: AppConfig = {
port,
db: {
host: "localhost",
port: 5432,
},
};
... What if I don't want to deal with all of that?
It starts to become cumbersome, right? But before we address this with a better solution, I want to briefly talk about a good strategy that other languages promote called Layered Configuration.
Layered Configuration and 12Factor App
Layered Configuration is an abstract concept for managing application or system settings by organizing them as independent layers that are combined to produce a final configuration.
In our context, it's a bit of what we have discussed already: having separate files for each environment so we can easily manage them.
On top of that, we can also bring in the 12Factor App, which is a set of 12 principles for building better and more scalable applications.
The third concept is called "Config" and defines that we should move configurations out of our code. It advocates for using configuration only as environment variables:
Personally, I'm not a huge fan of this idea. I mean, environment variables are cool but I'd rather have configuration (when non-sensitive) stored in our repository. This way, we can easily trace the changes and roll back if needed.
But also, I like to be able to override those values via environment variable if I need to, for example, run my application "as production" but maybe change the API URL only, so I can run the app locally.
In that sense, we can mix Layered Configuration and the 12Factor-app Config strategy into a new approach.
Layerfig
To address this problem and come up with a solution that implements these concepts, I've created Layerfig, a library that gives you this implementation out of the box.
It was heavily inspired by the official rust library for dealing with this problem: config-rs.
Here's how it works:
1. You create your configuration files:
./
├─ config/
│ ├─ base.json
│ ├─ local.json
│ └─ prod.json
└─ src
2. Now, you install the library
npm install @layerfig/config
3. Now, you create your config file that will handle and export the config object:
import { ConfigBuilder, z } from "@layerfig/config";
const Environment = z.enum(["local", "prod"]);
const env = Environment.parse(process.env.APP_ENV);
const configSchema = z.object({
appURL: z.url(),
});
export const config = new ConfigBuilder({
validate: (finalConfig) => {
return configSchema.parse(finalConfig);
},
})
.addSource("base.json")
.addSource(`${env}.json`)
.addSource(ConfigBuilder.createEnvVarSource())
.build();
... and that's it.
The sources you add will be loaded and merged in the sequence you've defined:
- load from base.json
- load from <env>.json and override base
- load from environment variables and override the two objects
Now, when you import your config object into your application, this object will be type-safe at both compile and runtime.
Key features
- It's server-side only. You can't use this in the browser since it relies on host files and Node.js APIs.
- Can be used in Node, Bun, Deno, and any JS engine that supports node:fs and node:path.
- You can use the built-in and exported zod/v4 or use your own schema library. All you need to do is return the result of the schema validation in the validate function.
- By default, only .json files are supported, but if you want .jsonc, .json5, .yml, or .toml, you can use the official parsers.
- You can use slots and define environment variables inside the config.
- Allows you to override values (including nested and deep properties) via environment variables
Check the documentation for more details and examples.
Conclusion
I hope I was able to show you how we can better manage application settings and how layerfig can help you in this case.
If you have any problems while using it or want a specific feature, feel free to engage in the layerfig github repository.
Resources
- https://layerfig.dev/
- https://12factor.net
- https://www.sciencedirect.com/topics/computer-science/configuration-layer
- https://help.ivanti.com/ap/help/en_US/em/2018/Content/Environment_Manager/Configuration_Layering.htm
- https://learning-notes.mistermicheels.com/architecture-design/reference-architectures/layered-architecture/