Skip to content

Sources

Sources define where the library reads your configuration from, such as files or environment variables.

For loading source from files, use new FileSource('[file-name].[ext]'):

import { ConfigBuilder, FileSource } from "@layerfig/config";
import { schema } from "./schema";

export const config = new ConfigBuilder({
  validate: (finalConfig) => schema.parse(finalConfig),
})
  .addSource(new FileSource("base.json"))
  .build();

In this case, base.json is a file located in the <root-project-dir>/config directory (or the one specified via absoluteConfigFolderPath).

To support various project needs and preferences, we provide parsers for other common configuration file formats:

… or you can create your own parser.

The ObjectSource layers a plain JavaScript object into your configuration. It is available from both the server and the client entry points, and it is the right choice when you want to avoid files entirely, override a value while debugging, or build a config in a test. It also supports slots, allowing you to use environment variables:

import { ConfigBuilder, ObjectSource } from "@layerfig/config/client";
import { schema } from "./schema";

export const config = new ConfigBuilder({
  validate: (finalConfig) => schema.parse(finalConfig),
})
  .addSource(
    new ObjectSource({
      baseURL: "${PUBLIC_BASE_URL}",
      randomValue: true,
    })
  )
  .build();

Since a framework like Vite adds PUBLIC_* environment variables to import.meta.env and makes them available on the client, your config object will contain all the type-checked values.

Consider the following scenario:

Your application’s configuration files are committed to version control and used to build a Docker image for production. When running this image locally for debugging, any change to a configuration value requires rebuilding the image. This process can be slow and results in testing a modified image rather than the actual production build.

To avoid this, use EnvironmentVariableSource to override configuration values at runtime without modifying the source files:

import {
  ConfigBuilder,
  FileSource,
  EnvironmentVariableSource,
} from "@layerfig/config";
import { schema } from "./schema";

export const config = new ConfigBuilder({
  validate: (finalConfig) => schema.parse(finalConfig),
})
  .addSource(new FileSource("base.json"))
  .addSource(new EnvironmentVariableSource())
  .build();

By default, the library expects environment variables with the following structure:

  • Prefix: "APP"
  • Prefix separator: "_"
  • Nested key separator: "__"

For example, the environment variable APP_port overrides the port key in your configuration.

Suppose your base.json contains:

{
  "appURL": "http://localhost:4444",
  "port": 4444,
  "appEnv": "local"
}

Define your schema and load the sources:

import {
  ConfigBuilder,
  FileSource,
  EnvironmentVariableSource,
} from "@layerfig/config";

export const config = new ConfigBuilder({
  validate: (finalConfig, z) => {
    const schema = z.object({
      appURL: z.url(),
      port: z.coerce.number().int().positive(),
      appEnv: z.enum(["local", "dev", "prod"]),
    });

    return schema.parse(finalConfig);
  },
})
  .addSource(new FileSource("base.json"))
  .addSource(new EnvironmentVariableSource())
  .build();

Run the application with overrides:

APP_appEnv=prod node index.js

Since the environment variable source is added last, its values take precedence.

port above is declared as z.coerce.number() rather than z.number() on purpose. base.json provides the number 4444, but APP_port=8080 provides the string "8080", and the schema has to accept both.

The same applies to booleans, with one extra trap:

const schema = z.object({
  // ✅ understands "true"/"false", "1"/"0", "yes"/"no", "on"/"off"
  debug: z.stringbool(),
  // ❌ Boolean("false") === true — this is always true
  // debug: z.coerce.boolean(),
});

Use __ (double underscores) as a separator to target nested keys:

import {
  ConfigBuilder,
  FileSource,
  EnvironmentVariableSource,
  z,
} from "@layerfig/config";

const schema = z.object({
  api: z.object({
    vendor: z.object({
      aws: z.object({
        apiToken: z.string(),
      }),
    }),
  }),
});

const config = new ConfigBuilder({
  validate: (finalConfig) => schema.parse(finalConfig),
})
  .addSource(new FileSource("base.json"))
  .addSource(new EnvironmentVariableSource())
  .build();
APP_api__vendor__aws__apiToken=12345 node index.js

A numeric key segment creates (or updates) an array item:

APP_servers__0__host=a.example.com APP_servers__1__host=b.example.com node index.js
{
  "servers": [{ "host": "a.example.com" }, { "host": "b.example.com" }]
}

You can customize the prefix, prefix separator, and nested-key separator:

import {
  ConfigBuilder,
  FileSource,
  EnvironmentVariableSource,
} from "@layerfig/config";
import { schema } from "./schema";

const config = new ConfigBuilder({
  validate: (finalConfig) => schema.parse(finalConfig),
})
  .addSource(new FileSource("base.json"))
  .addSource(
    new EnvironmentVariableSource({
      prefix: "VULCAN",
      prefixSeparator: "--",
      separator: "----",
    })
  )
  .build();
VULCAN--api----vendor----aws----apiToken=12345 node index.js

Sources are merged in the order they are added, and each one is layered on top of the result so far. Objects are merged deeply, so a later source only has to declare the keys it changes:

// base.json                    // production.json
{ "db": { "host": "localhost",  { "db": { "host": "db.internal" } }
          "port": 5432 } }

// result
{ "db": { "host": "db.internal", "port": 5432 } }

Slots are resolved after all sources are merged, so a ${self.*} reference can point at a value contributed by any layer. See Slots.