Skip to content

Slots

It’s common to define your configuration in a file while also sourcing values from environment variables.

Consider the following configuration file:

// config/base.json
{
  "appURL": "http://localhost:3000",
  "port": 3000
}

Notice that the port number is used in two places. Instead of hardcoding this value, you can use a PORT variable from your environment, for example, in a .env file:

PORT=3000

To reference this environment variable, you can use a “slot”:

// config/base.json
{
  "appURL": "http://localhost:${PORT}",
  "port": "${PORT}"
}

When Layerfig processes your configuration, it finds slots and replaces them with the corresponding environment variable’s value:

${PORT} => process.env.PORT => 3000

Every slot is wrapped in ${...}. The table below is the complete grammar — the rest of this page explains each form in detail.

Syntax Resolves to
${VAR} The VAR environment variable
${VAR::-fallback} VAR, or the literal fallback if VAR is not set
${A::B} The first of A, B that is set
${A::B::-fallback} The first of A, B that is set, or the literal fallback
${self.a.b} The value at a.b in the merged configuration
"a-${X}-${Y}" Several slots in one value, replaced independently

For convenience, you can also reference other values of your configuration. This helps avoid duplication and keeps your configuration consistent.

In the previous “port” example, instead of defining the ${PORT} slot in two places, you can use a self-referencing slot:

// config/base.json
{
  "appURL": "http://localhost:${self.port}",
  "port": "${PORT::-3000}"
}

Layerfig looks for the self.* syntax and uses the value after the dot as an “object path” to find the value in your configuration. In this case, it will look for the port value.

The final configuration will be:

{
  "appURL": "http://localhost:3000",
  "port": "3000"
}

Slots are resolved only after all sources are merged, which means a self-referencing slot can point to a value defined in any other source, no matter the order they were added:

// config/base.json
{
  "foo": {
    "value": "${MY_VALUE::-bar}"
  }
}
// config/production.json
{
  "foo": {
    "anotherValue": "test-${self.foo.value}"
  }
}

Even though production.json doesn’t define foo.value, the slot resolves against the merged configuration:

{
  "foo": {
    "value": "bar",
    "anotherValue": "test-bar"
  }
}

The same applies to values coming from the EnvironmentVariableSource or an ObjectSource.

Because replacement happens after every source is merged, a value that arrives from an environment variable can itself contain a slot:

APP_appURL='http://localhost:${PORT}' PORT=3000 node index.js
config.appURL; // "http://localhost:3000"

This is useful when a deployment platform injects one value that needs to be composed from others at runtime.

By default, Layerfig uses $ as the slot prefix, but you can change it by passing the slotPrefix option. Only the prefix changes — the braces stay:

// slotPrefix: "__"
{
  "port": "__{PORT}"
}

Sometimes, you may want to try multiple environment variables for a single value, using a specific order of priority.

For example, imagine you want to determine the current Git branch. This value could come from different sources:

  1. process.env.GIT_REF
  2. process.env.REF
  3. .branch (from the same configuration file)

To do this, you can use the extended slot syntax, separating each variable name with a double colon:

// config/base.json
{
  "branch": "${GIT_REF::REF::self.branch}"
}

Layerfig processes this from left to right, checking for GIT_REF, then REF, and so on, using the first environment variable it finds. If none of the references in the chain resolve, the slot resolves to undefined — see Unresolved slots. To guarantee a value, add a literal fallback.

In addition to chaining variables, you can also provide a literal fallback value if none of the environment variables are set.

This is done by adding the :- operator to the extended slot syntax. This works even with a single variable:

// config/base.json
{
  "port": "${PORT::-3000}"
}

If the PORT environment variable is not defined, Layerfig will use 3000 as the value.

You can combine this operator with variable chaining for more complex cases:

// config/base.json
{
  "branch": "${GIT_REF::REF::MAIN_REF::-main}"
}

If none of the GIT_REF, REF, or MAIN_REF environment variables are found, the value will fall back to the literal string main.

Here are a few things to keep in mind when working with slots.

If a slot cannot be resolved — the environment variable is not set, the self.* path doesn’t exist, and there is no literal fallback — the value resolves to undefined and the key is removed from the final configuration:

// config/base.json — APP_URL is not set
{
  "appURL": "${APP_URL}"
}
config.appURL; // undefined — the key is not present

This applies to partial matches too: if any slot inside a longer string is unresolved, the whole value becomes undefined.

{ "appURL": "http://localhost:${MISSING}" } // => undefined, not the literal text

If your schema expects a defined value, validation will fail with a “missing key” style error rather than a “bad value” one. That is usually the first clue that a slot didn’t resolve.

For array items, a value that resolves to undefined is removed from the array. For example, let’s say only ORIGIN_1 is defined in your environment variables:

ORIGIN_1=*

The following config:

allowOrigins: ["${ORIGIN_1}", "${ORIGIN_2}"]

Will be resolved to:

const config = {
  allowOrigins: ["*"], // ORIGIN_2 was discarded.
};

Use a literal fallback whenever a key must always be present.

The runtimeEnv option can be process.env in the server config, import.meta.env in the client, or even a plain object. Node’s process environment is a record Record<string, string|undefined>, but import meta env can hold non-string values such as booleans and numbers.

Layerfig supports all these value types, but a resolved slot is always inserted as a string, so in your validate(finalConfig) function you will most likely have string values.

const finalConfig = {
  appURL: "http://localhost:3000",
  port: "3000",
  dev: "true",
  prod: "false",
};

To handle this, you can use a validation schema to coerce the string value into the type you expect. For example, with Zod:

import { z } from "@layerfig/config";

const schema = z.object({
  appURL: z.string(),
  port: z.coerce.number().positive().int(),
  dev: z.stringbool(),
  prod: z.stringbool(),
});

The z.coerce.* and z.stringbool() functions will parse the string value and transform it into the type you want.

This is true no matter which parser you use. Formats that do not require quoting strings, such as YAML, are no exception:

# config/base.yaml
version: ${APP_VERSION}

Slots are resolved after the file is parsed, so APP_VERSION="1" resolves to the string "1" and not to the number 1. If you need a number, coerce it in your validation schema.