Simple TypeScript library for managing complex configuration in your js/ts node/web projects.
Install via npm:
npm install config-layersThen define your config schema, and you can load and merge multiple configuration layers.
import {LayeredConfig} from 'config-layers';
type Schema = { apikey: string; backendUrl: string };Having defined the config schema, you can load the config and use it in your application:
export const config = LayeredConfig.fromLayers<Schema>([
{ name: "default", config: JSON.parse(fs.readFileSync('config/default.json', 'utf-8')) }, //least priority
{ name: "env", config: { apikey: process.env.APIKEY }}, //last = highest priority
]);
console.log(config.apikey); // Access config values directly
console.log(config('backendUrl', 'https://fallback.url')); // Access with fallback
console.log(config.__inspect('apikey')); // Inspect which layer provided the valueThis library lets you organize your config settings into named layers, loaded from lowest to highest priority. If a setting exists in a higher layer, it will be used; otherwise, the next (lower) layer is checked. This helps you combine config from different places into one object.
Defaults can have the lowest priority, while environment-specific values (like from dotenv files or environment variables) can override them.
All the merging is handled for you in a type-safe way. You define the config structure using a TypeScript type or interface. You can also use validation libraries like Zod or JSON Schema if you want, but it’s not required. If you use any instead of a type, you lose type safety, which is one of the main benefits of using TypeScript. For runtime validation, see the Zod validation example.
- Easy to integrate into any project
- Layered configuration merging
- Deep key access (dot notation)
- Fallback values and custom not-found handlers
- Configuration inspection (see from which layer the valueF comes from)
- Immutable config proxy, frozen config object
- Runs on iOS 15 / Safari 15 (iPhone 6s); no regexes in the shipped bundle
- TypeScript support
The library is built for iOS 15 / Safari 15 — an iPhone 6s is the oldest device it is verified
against — alongside Chrome 87, Firefox 78 and Edge 88. The build target is pinned explicitly in
vite.config.ts rather than inherited from the bundler's shifting default.
There is one hard floor that cannot be lowered: the config handle is a
Proxy,
and Proxy cannot be polyfilled — the interception it provides has no ES5 equivalent. That
puts the real minimum at Chrome 49 / Firefox 18 / Safari 10 / Edge 12, and it means
Internet Explorer is not supported at any version and never can be. If you need IE, this is not
the library for you.
Everything else the library uses (optional chaining, nullish coalescing, class fields) is comfortably
below the stated floor. Notably there are no regular expressions anywhere in the shipped bundle —
key-path splitting is a hand-written scanner precisely because the obvious regex needs lookbehind,
which Safari only gained in 16.4. tests/dist-smoke.test.ts enforces that, because a build target
alone does not: bundlers silently downgrade an unsupported regex literal to new RegExp("…"), which
parses fine and then throws at runtime on the first key lookup.
Note: full article in docs/why.md
Config Layers is a TypeScript library for managing complex hierarchical configuration objects with support for inspection, fallbacks, and immutability. Useful for applications that need to rely on configuration from multiple sources and detail level (e.g., defaults, environment, country, region, page template).
NOTE: Examples use dynamic imports due to Vitest doctest limitations.
Feel free to import traditionally or dynamically as shown in the examples.
//import {LayeredConfig} from 'config-layers';
type Schema = {
apikey: string;
useMocks: boolean;
userContext: {
userId: string;
roles: string[];
};
};
const layers = [
{ name: "default", config: { useMocks: false, } }, //least priority
{ name: "env", config: { apikey: "2137-dev-apikey", useMocks: true } },
{ name: "user", config: { userContext: { userId: "user123", roles: ["admin", "user"] } } }, //highest priority
];
const cfg = LayeredConfig.fromLayers<Schema>(layers);
//It resolves from the highest priority layer
expect(cfg.apikey).toBe('2137-dev-apikey'); // defined only in `env` layer
expect(cfg.useMocks).toBe(true); // `env` layer overrides `default` => true
expect(cfg.userContext.userId).toBe('user123'); // compound values are also acceptedPlease note that we don't validate your config objects against the schema at runtime. The library relies on TypeScript's static type checking to ensure that the configuration objects conform to the specified schema. This means that its possible to inject invalid config objects at runtime - bypassing TypeScript checks. Always ensure that your configuration objects match the expected types to avoid runtime errors. In yout code it must be relatively easy to employ i.e. Zod to validate the config object, either as whole or layer by layer, and it is with Layered Config. Consult the examples folder for more usage patterns.
LayeredConfig.fromLayers(layers, options?): Create a layered config proxy.LayeredConfig.fromLayersAsync(layers, options?): Promise to be awaited that resolves all layers - for async loading.cfg.${key}: Get a value by key.cfg[key]: Get a value by key.cfg.__inspect(key): Inspect the source and value for a key.cfg(key, fallback?): Get a value with fallback.cfg.getAll(key): Get all values for a key from all layers, returns asArray<{layer: string, value: any}>.cfg.__derive(...): Derive a new config with additional/overridden layers or options.
The config proxy provides a few sepcial methods to assist you.
__inspect- to inspect which layer provided the value for a given key__derive- to derive a new config with additional/overridden layers or optionsget- callback notation to get a value, with optional fallbackgetAll- callback notation to get all values for a key, from all layers, and return them as anArray<{layer: string, value: any}>so that you can easily unpack them
These words are reserved, so you can't create config keys with these names.
You can access config values directly as properties, with index access, or use the callback notation to provide a fallback value.
cfg.apikey; // direct property access
cfg['apikey']; // index access
cfg('apikey'); // callback notation
cfg('apikey', 'default-apikey'); // with fallback
cfg.nested.field; // direct property access
cfg['nested.field']; // index access
cfg('nested.field'); // callback notation
cfg('nested.field', 'default-value'); // with fallbackIn case your config defines values that are arrays, the simple access - returns only the highest priority value. If you need to get all values from all layers, use the getAll special method.
cfg.getAll('arrayKey'); // returns Array<{layer: string, value: any}>
cfg.getAll('enabled.features').map(item=>item.value); // get only the values, as array of arraysTo flatten the array of arrays, use flatMap
//import {LayeredConfig} from 'config-layers';
const layers = [
{ name: "1", config: { "features": ["f1", "f2","f4"] } },
{ name: "2", config: {"features": ["f3"] } } ,
{ name: "3", config: {"features": ["f3","f4", "f5"] } },
];
const cfg = LayeredConfig.fromLayers<{features: string[]}>(layers);
expect(cfg.features).toEqual(['f3', 'f4', 'f5']); // highest priority only
//get all values from all layers
expect(cfg.getAll('features').map(item=>item.value)).toEqual([
['f3','f4','f5'],
['f3'],
['f1','f2','f4'],
]);
//flatten the array of arrays with flatMap()
expect(cfg.getAll('features').flatMap(item=>item.value)).toEqual([
'f3','f4','f5',
'f3',
'f1','f2','f4',
]);
//Get unique values only, with help of the Set() and flatmap()
expect(Array.from(new Set(
cfg.getAll('features').flatMap(item=>item.value)
))).toStrictEqual(['f3','f4','f5','f1','f2']);When a config value is not found, the default behavior is to log a warning and resolve the key to
undefined:
[config-layers] Key not found: apikey
A missing key is usually a typo or a layer that failed to load, and taking the application down for it is rarely what you want — but it should not pass silently either.
Each key is warned about once per config, so a missing key read inside a loop does not flood the console. The record of what has already been warned belongs to the config rather than to the module, so two unrelated configs do not silence each other; a derived config inherits its parent's, and will not re-report a key the parent has already covered.
You can replace this entirely with a custom notFoundHandler. Whatever it returns becomes the
resolved value, so it can supply a default, route to your own logger, or throw if you would rather
fail loudly:
import {LayeredConfig} from 'config-layers';
const strict = LayeredConfig.fromLayers<{apikey: string}>(
[{ name: "default", config: {} }],
{
notFoundHandler: key => { throw new Error(`Missing config key: ${String(key)}`); }
}
);Or to return a default value for any missing key:
//import {LayeredConfig} from 'config-layers';
const cfg = LayeredConfig.fromLayers<{apikey: string}>(
[{ name: "default", config: {} }], //the config is empty in this example
{
notFoundHandler: key => { //when key is not found, this handler is called
return 'XD'; //return a default value for any missing key
}
}
);
expect(cfg.anything).toBe('XD'); // the handler is called for any missing keyThe handler is reached only by a genuine miss. Language and library protocol lookups — toJSON,
toString, Symbol.toPrimitive, $$typeof and friends — are answered without consulting it, so
console.log, JSON.stringify and string coercion never trigger a spurious warning.
By default the config object is frozen, so that you can't mutate it. This is to ensure immutability and prevent accidental changes to the configuration at runtime. To replace or add the config layers, you should use the __derive method, which creates a new config object based on the existing one, with the specified changes.
Precisely what this does and does not cover:
- Your layer objects are never touched. The config takes its own deep copy of everything it merges, so constructing one will not freeze arrays or objects you still hold a reference to.
- Resolved values are a snapshot. Mutating a layer object after
fromLayershas returned does not change what the config resolves — through eithercfg.a.borcfg['a.b']. (__inspectandgetAllreport per-layer provenance and do read the layer objects, so they will show such a change.) Date,RegExp,Map,Set, typed arrays and class instances are copied too. They are reconstructed rather than copied field by field, so they keep their type, their prototype and their methods —cfg.created instanceof Dateandcfg.creds.describe()both work, and mutating your original afterwards does not reach the config.- Two things are not copied, because they cannot be. Functions are shared by reference — a
closure cannot be cloned. And private class fields (
#x) are unreachable from outside the class, so a method that depends on one will throw on the copy; keep such objects out of your config layers, or expose the state as a normal property. Object.freezecannot reach internal slots. A frozenMapstill acceptsmap.set(...)and a frozenDatestill acceptssetTime(...). Because these are the config's own copies, doing so can no longer affect your objects — but it does still mutate the config's value.
If you need to modify the config object (not recommended), you can disable freezing by setting the freeze option to false. Note this only unfreezes the config's own copy; it does not start sharing your layer objects.
import {LayeredConfig} from 'config-layers';
const cfg = LayeredConfig.fromLayers<{apikey: string}>(
[{/*...*/}], //provide your config layers here
{
freeze: false //mark the config object as mutable
}
);By default not defined keys in one of the config layer, null and undefined values are treated as transparent, so values from other layers are used. However you can change the behavior, if null or undefined is accepted as a config value for you, you can set it using relevant option.
import {LayeredConfig} from 'config-layers';
const cfg = LayeredConfig.fromLayers<{apikey: string}>(
[{/*...*/}], //provide your config layers here
{
acceptNull: true,
acceptUndefined: true,
}
);When merging configuration layers, you can specify how arrays should be handled. By default, arrays are overwritten (override).
Available strategies:
override(default): The higher priority array completely replaces the lower priority one.concat: The higher priority array is appended to the lower priority one.union: The arrays are merged, and only unique values are kept.
You can set a global strategy or use a local override for specific fields.
//import {LayeredConfig} from 'config-layers';
const layers = [
{ name: 'base', config: { tags: ['a', 'b'], flags: ['f1'] } },
{ name: 'user', config: { tags: ['c'], flags: ['f2'] } },
];
const cfg = LayeredConfig.fromLayers(layers, {
arrayMergeStrategy: 'concat'
});
expect(cfg.tags).toEqual(['a', 'b', 'c']);
expect(cfg.flags).toEqual(['f1', 'f2']);To customize merging for a specific field only, use arrayLocalMergeStrategyNameSuffix. This allows you to define a sibling field in your config that specifies the strategy for that array.
//import {LayeredConfig} from 'config-layers';
const layers = [
{ name: 'base', config: { list: ['a', 'b'] } },
{
name: 'override',
config: {
list: ['c'],
list_strategy: 'concat' // local override
}
}
];
const cfg = LayeredConfig.fromLayers(layers, {
arrayMergeStrategy: 'override', // global default
arrayLocalMergeStrategyNameSuffix: '_strategy'
});
expect(cfg.list).toEqual(['a', 'b', 'c']);The library is suitable for localization or similar use cases. It provides graceful handling of missing keys via fallbacks or a custom not-found handler.
//import {LayeredConfig} from 'config-layers';
// Specify the type for the labels
type Labels = { button: string };
const labels = LayeredConfig.fromLayers<Labels>([
{ name: "default", config: { button: "Accept cookies" } },
{ name: "localized", config: { button: "I would like the biscuits, please!" } },
], {
notFoundHandler: key => `<<${key}>>`,
});
// It returns the value from the highest priority layer
expect(labels.button).toBe('I would like the biscuits, please!');
//The callback notation allows providing a fallback value
expect(labels('button2', 'cookie msg2')).toBe('cookie msg2');
//When there is no fallback, the notFoundHandler is called
expect(labels.button2).toBe('<<button2>>'); The inspection special word is prefixed with double underscore to avoid name collisions with your config keys. If you need to use keys starting with double underscore, consider using the callback notation. If your config keys must rely on dots within flat array, use the callback notation as well and double the dots in your code.
//import {LayeredConfig} from 'config-layers';
const layers = [
{name: "default", config: JSON.parse(`{
"regularName": "1",
"special.name": "2"
}`)},
];
const cfg = LayeredConfig.fromLayers(layers);
expect(cfg['regularName']).toBe('1'); // works as expected
expect(cfg.regularName).toBe('1'); // works as expected
expect(cfg('special..name')).toBe('2'); // double dot avoids nesting//import {LayeredConfig} from 'config-layers';
const layers = [
{name: "default", config: {useMocks: false, envName: "not set", path: "cwd"}},
{name: "env", config: {envName: "development", apikey: "2137-dev-apikey", useMocks: true}},
{name: "user", config: {session: "abcd", userContext: {userId: "user123", roles: ["admin", "user"]}}},
];
const cfg = LayeredConfig.fromLayers(layers);
expect(cfg.__inspect('apikey')).toStrictEqual({
key: 'apikey',
"layers": [
{"isActive": false, "isPresent": false,"layer": "user", "value": undefined,},
{"isActive": true, "isPresent": true, "layer": "env", "value": "2137-dev-apikey",},
{"isActive": false, "isPresent": false,"layer": "default","value": undefined,},
],
resolved: { source: 'env', value: '2137-dev-apikey' }
})You can create a new configuration by adding or overriding layers, or by changing options (such as the not-found handler), without mutating the original config. This is useful for scenarios like feature toggles, user overrides, or context-specific settings.
//import {LayeredConfig} from 'config-layers';
const base = LayeredConfig.fromLayers([
{ name: 'default', config: { apiUrl: 'https://api.example.com', timeout: 5000 } },
{ name: 'env', config: { timeout: 3000 } },
]);
// Derive a new config with an additional layer
const featureConfig = base.__derive('feature', { apiUrl: 'https://feature-api.example.com' });
expect(featureConfig.apiUrl).toBe('https://feature-api.example.com');
expect(base.apiUrl).toBe('https://api.example.com'); // original is unchanged
// Derive with a custom notFoundHandler
const safeConfig = base.__derive({ notFoundHandler: key => `Missing: ${key}` });
expect(safeConfig.nonexistent).toBe('Missing: nonexistent');
// Combine both: add a layer and set options
const custom = base.__derive('user', { timeout: 1000 }, { notFoundHandler: key => 'N/A' });
expect(custom.timeout).toBe(1000);
expect(custom.nonexistent).toBe('N/A');cfg.__derive(options): Returns a new config with new options (e.g., a custom notFoundHandler).cfg.__derive(layerName, layerConfig): Returns a new config with the given layer added or replaced.cfg.__derive(layerName, layerConfig, options): Returns a new config with both a new/overridden layer and new options.
The original config is never mutated. All derived configs are independent proxies.
Consult the unit tests and examples folder for more usage patterns.
This project uses Vitest and vite-plugin-doctest for testing and documentation.
Run tests with:
npm testTo run tests also for the code examples, install the dependencies in the examples folder:
cd examples
npm install
cd ..
npm test-
Why? - the motivation and reasoning behind the library is explained in the docs/why.md file.
-
Tips and tricks for implementing configuration management are available in the docs/tips-and-tricks.md file.
-
Other libraries focused on config management:
- convict - schema-based config management with validation and environment variable support.
- config - feature-rich opinionated config library with file-based layers and environment support.
- nconf - hierarchical config with multiple sources and priority levels. Batteries included,
- dotenv-flow - extended version of dotenv supporting multiple .env files for different environments.
- dotenv - loads env vars from .env files, often used alongside other config libraries.
- Zod - schema validation library that can be used to validate config objects at runtime.
- ajv - another JSON Schema validator for runtime config validation, can be used together with JSON Schema.
Contributions are welcome! Please open issues or pull requests for improvements or bug fixes.
For all the initial insights, testing and proofreading - thank to Karol Witkowski!
For helping me in writing the docs and code examples - thanks to Junie by JetBrains!
Unlicense. Take it and use it for any purpose, without any restrictions.