Knead

Search every guide and package page, down to section headings.

↑↓ move Enter open Esc close

Techniques

Configuration

Define a typed configuration schema once, load it from environment variables, async loaders and code at startup, and inject it anywhere.

On this page 14 sections

@knead-js/config reads configuration once, while the application boots. You describe the settings with a schema, ConfigModule merges the sources and validates the result, and any provider reads the typed values with inject(). A missing or invalid setting stops startup with an error that names the variable but never shows its value.

Terminal
bun add @knead-js/config @valibot/to-json-schema valibot

The examples on this page use Valibot. Zod 4.2+ and ArkType 2.1.28+ work too; see schema libraries.

#Define the configuration

Declare every setting the application reads in one object schema and pass it to defineConfig():

app-config.ts
import { defineConfig } from '@knead-js/config';
import { toStandardJsonSchema } from '@valibot/to-json-schema';
import * as v from 'valibot';

export const AppConfig = defineConfig(
  toStandardJsonSchema(
    v.object({
      // Environment variables are strings: parse numbers and booleans in the schema.
      port: v.optional(v.pipe(v.string(), v.toNumber(), v.integer()), '3000'),
      databaseUrl: v.pipe(v.string(), v.url()),
      jwtSecret: v.pipe(v.string(), v.minLength(32)),
      signupsOpen: v.optional(
        v.pipe(
          v.picklist(['true', 'false']),
          v.transform((value) => value === 'true'),
        ),
        'false',
      ),
    }),
  ),
  { name: 'AppConfig' },
);

defineConfig() returns a ConfigHandle: an injection token for the validated Config object that also remembers the schema and its declared keys. The name option becomes the token's description (default 'Config'), which appears in dependency injection errors.

Environment variables are always strings, so the schema parses them: v.toNumber() turns PORT=8080 into 8080, and the signupsOpen pipe turns 'true'/'false' into a boolean. A schema that expects a number directly (v.number()) rejects every value an environment variable can hold. Defaults are schema input too, which is why the port default is the string '3000'.

#Register the module and inject it

Import ConfigModule.forRoot(handle) once, in the root module:

app.module.ts
import { ConfigModule } from '@knead-js/config';
import { Module } from '@knead-js/core';
import { AppConfig } from './app-config.js';
import { SignupService } from './signup.service.js';

@Module({
  imports: [ConfigModule.forRoot(AppConfig)],
  providers: [SignupService],
})
export class AppModule {}

ConfigModule is a global module, so every provider in the application can inject the handle without importing anything:

signup.service.ts
import { Injectable, inject } from '@knead-js/core';
import { AppConfig } from './app-config.js';

@Injectable()
export class SignupService {
  readonly #config = inject(AppConfig);

  isOpen(): boolean {
    return this.#config.get('signupsOpen'); // boolean, parsed from 'true' or 'false'
  }

  port(): number {
    return this.#config.get('port'); // number, 3000 when PORT is unset
  }
}

config.get(key) returns one value with the schema's output type (number for port, boolean for signupsOpen), and an unknown key is a compile error. config.all() returns the whole validated object.

Configuration is loaded and validated while the application is created (KneadFactory.create() or compile() in tests), before any onModuleInit hook runs. A failure rejects that call, so the server never starts with a broken configuration.

#Environment variable names

Each source's names are converted from SCREAMING_SNAKE_CASE to camelCase, and only names that convert to a key the schema declares are read:

Environment variableConfig key
PORTport
DATABASE_URLdatabaseUrl
JWT_SECRETjwtSecret
OAUTH2_CLIENT_IDoauth2ClientId

A name is SCREAMING_SNAKE_CASE when it starts with an uppercase letter and has only uppercase letters and digits in words separated by single underscores. Other names (path, Foo_Bar, _PORT, DATABASE__URL) are ignored, and so are names the schema does not declare, such as HOME or PATH. A variable whose value is undefined is skipped; a variable set to the empty string is present, and the schema decides whether '' is valid.

Schema keys must be camelCase that some environment name converts to. defineConfig() throws a ConfigDefinitionError for any other key, with a suggestion when there is one:

Output
Config key "DATABASE_URL" can never be read from SCREAMING_SNAKE_CASE sources; declare it in camelCase ("databaseUrl").

Bun loads .env, .env.<NODE_ENV> and .env.local into process.env by itself, so there is no dotenv step. See Bun's environment variable docs.

#Key collisions

Digits make two names convert to the same key: OAUTH2_CLIENT_ID and OAUTH_2_CLIENT_ID are both oauth2ClientId. When two names in the same source map to one declared key, loading fails with a ConfigKeyCollisionError instead of picking one:

Output
process.env: OAUTH2_CLIENT_ID and OAUTH_2_CLIENT_ID both map to config key "oauth2ClientId".

The prefix names the source (process.env, or loader #0 for the first loader). Only declared keys can collide. The same key arriving from two different sources is not a collision: the source with the higher precedence wins.

#Sources and precedence

Configuration comes from up to three kinds of source. From lowest to highest precedence:

  1. process.env, read when the module is created.
  2. Loaders, in array order: a later loader wins over an earlier one and over process.env.
  3. values passed in code, which win over everything.

The sources are merged key by key and the merged object is validated once, so a schema default applies only when no source sets the key.

#Values in code

values take camelCase keys and schema input types, so for this schema they are strings, exactly like environment variables:

app.module.ts
import { ConfigModule } from '@knead-js/config';
import { Module } from '@knead-js/core';
import { AppConfig } from './app-config.js';

@Module({
  imports: [
    // Code values are schema input, like environment strings, and win over every other source.
    ConfigModule.forRoot(AppConfig, { values: { port: '8080', signupsOpen: 'true' } }),
  ],
})
export class AppModule {}

Keys the schema does not declare are ignored, and so are keys whose value is undefined. Values are useful for settings that are fixed per deployment artifact, and in tests (see testing).

#Loaders

A loader is a function that returns an object of strings, or a promise of one (ConfigLoader). Its keys are converted exactly like environment variable names, so a loader returns DATABASE_URL, not databaseUrl:

app.module.ts
import { type ConfigLoader, ConfigModule } from '@knead-js/config';
import { Module } from '@knead-js/core';
import { AppConfig } from './app-config.js';

/** Reads a JSON object of SCREAMING_SNAKE_CASE names, such as a mounted secrets file. */
export function jsonFile(path: string): ConfigLoader {
  return async function jsonFile() {
    const file = Bun.file(path);
    if (!(await file.exists())) return {};
    return (await file.json()) as Record<string, string>;
  };
}

@Module({
  imports: [
    ConfigModule.forRoot(AppConfig, {
      // Later loaders win: the secrets file overrides the shared defaults file.
      loaders: [jsonFile('/etc/shop/defaults.json'), jsonFile('/run/secrets/shop.json')],
    }),
  ],
})
export class AppModule {}

All loaders start at the same time and run once, after useFactory (for forRootAsync) has settled. A loader that throws or rejects fails startup with a ConfigLoaderError named after its index and function name, Config loader #0 (jsonFile) failed., with the original error as its cause. A loader that returns something other than an object fails the same way, with the cause TypeError: Config loader must return an object.. An anonymous function appears as (anonymous), so give loaders a name.

#Loaders that need other providers

When a loader needs a client from another module, such as a secret store, use ConfigModule.forRootAsync(). Its imports make that module's exports available, and its useFactory returns the sources ({ loaders, values }):

config.module.ts
export const ShopConfigModule = ConfigModule.forRootAsync(AppConfig, {
  imports: [SecretsModule],
  useFactory: () => {
    // useFactory runs in the injection context: inject() here, not inside a loader.
    const store = inject(SECRET_STORE);
    return { loaders: [() => store.read('shop/production')] };
  },
});

@Module({ imports: [ShopConfigModule] })
export class AppModule {}

useFactory runs inside the injection context, so it can call inject(). Loaders cannot: they start after an await, and inject() inside one fails startup with a ConfigLoaderError whose cause is an InjectionContextError. Call inject() in useFactory and close over the result, as above.

The store in this example is ordinary code from the same file:

async.ts
import { ConfigModule } from '@knead-js/config';
import { InjectionToken, inject, Module } from '@knead-js/core';
import { AppConfig } from './app-config.js';

export interface SecretStore {
  /** Returns the secrets under `path`, keyed by SCREAMING_SNAKE_CASE name. */
  read(path: string): Promise<Record<string, string>>;
}

export const SECRET_STORE = new InjectionToken<SecretStore>('SECRET_STORE');

/** A store that reads a Vault KV v2 secret over HTTP. */
class VaultSecretStore implements SecretStore {
  constructor(
    private readonly address: string,
    private readonly token: string,
  ) {}

  async read(path: string): Promise<Record<string, string>> {
    const res = await fetch(new URL(`/v1/secret/data/${path}`, this.address), {
      headers: { 'x-vault-token': this.token },
    });
    // Name the path and status only: never put secret values in an error message.
    if (!res.ok) throw new Error(`Vault read of ${path} failed with ${res.status}`);
    const body = (await res.json()) as { data: { data: Record<string, string> } };
    return body.data.data;
  }
}

@Module({
  providers: [
    {
      provide: SECRET_STORE,
      useFactory: () =>
        new VaultSecretStore(process.env.VAULT_ADDR ?? '', process.env.VAULT_TOKEN ?? ''),
    },
  ],
  exports: [SECRET_STORE],
})
export class SecretsModule {}

export const ShopConfigModule = ConfigModule.forRootAsync(AppConfig, {
  imports: [SecretsModule],
  useFactory: () => {
    // useFactory runs in the injection context: inject() here, not inside a loader.
    const store = inject(SECRET_STORE);
    return { loaders: [() => store.read('shop/production')] };
  },
});

@Module({ imports: [ShopConfigModule] })
export class AppModule {}

#Outside a module

loadConfig(handle, sources?, env?) runs the same merge and validation without a module and resolves to a Config. Its third argument replaces process.env, which is handy for scripts and tests:

scripts/migrate.ts
import { loadConfig } from '@knead-js/config';
import { AppConfig } from './app-config.js';

/** Reads AppConfig outside a module, for example in a migration script. */
export async function migrationTarget(env: Record<string, string | undefined> = process.env) {
  const config = await loadConfig(AppConfig, {}, env);
  return config.get('databaseUrl');
}

#Validation errors

When validation fails, startup rejects with a ConfigValidationError that lists every problem, one line per key:

Output
Config validation failed:
  PORT (port): Invalid value
  DATABASE_URL (databaseUrl): Missing value
  JWT_SECRET (jwtSecret): Invalid value

Each line shows the name in the source that supplied the winning value (here the environment variable), the config key, and one of two messages:

  • Missing value: no source set the key.
  • Invalid value: some source set it and the schema rejected it.

When the value came from values, the name is the camelCase key (jwtSecret (jwtSecret)). When the key is missing, the name is the environment form of the key. The same problems are on error.issues as ConfigIssue objects:

JSON
[
  { "key": "port", "name": "PORT", "path": ["port"], "message": "Invalid value" },
  { "key": "databaseUrl", "name": "DATABASE_URL", "path": ["databaseUrl"], "message": "Missing value" },
  { "key": "jwtSecret", "name": "JWT_SECRET", "path": ["jwtSecret"], "message": "Invalid value" }
]

The schema's own messages are deliberately discarded, because a validation message can quote the rejected input, and the input may be a secret. For the same reason path keeps only the top-level key: deeper segments can be derived from values. An issue the schema reports on the whole object, or on a key it does not declare, appears as (schema) ((root)): Invalid value. A schema that throws instead of returning issues fails with ConfigValidationError: Config schema threw while validating. and an empty issues list, and the thrown error is dropped, since it may quote a value.

#Secrets

Configuration usually holds credentials, so Config keeps them out of logs and responses by default:

  • JSON.stringify(config) writes [redacted] for every key, for example {"port":"[redacted]","jwtSecret":"[redacted]"}, and so does console.log(config) or Bun.inspect(config) (Config {"port":"[redacted]",...}).
  • Error messages name variables and keys, never values.
  • The validated object is deep-frozen, so nothing can change a setting after boot. Changing process.env later has no effect either: the configuration was read once.

config.get(key) and config.all() return the real values: that is their purpose. Pass a secret only to the code that uses it, and do not log the result of all(). When a value does reach a log line, Knead's log redaction still masks values under sensitive key names such as jwtSecret and credentials inside URLs, but it cannot recognize a secret stored under an innocent name.

Two more rules keep secrets out of errors:

  • A loader's own error becomes the cause of ConfigLoaderError unchanged, so a loader must not put configuration values in its error messages (the Vault store above names only the path and status). The cause is non-enumerable, like a native one, so serializers that walk own keys skip it.
  • Because Config freezes the validated output in place, an object you pass in values that the schema returns unchanged is frozen too.

#Several configuration handles

An application can split its settings across several handles, each registered with its own ConfigModule.forRoot() (or forRootAsync()). Every handle reads only its own declared keys, so two handles can share the environment without seeing each other's settings.

#Schema libraries

defineConfig() needs to know which keys the schema declares, so the schema must implement both Standard Schema (to validate) and Standard JSON Schema (~standard.jsonSchema, to list its properties). The top level must be a plain object schema with declared properties, not a $ref, allOf or anyOf.

LibraryMinimum versionHow to pass it
Zod4.2.0z.object({ ... }) directly
ArkType2.1.28type({ ... }) directly
Valibot1.5.0, with @valibot/to-json-schema 1.5.0toStandardJsonSchema(v.object({ ... }))

A schema without Standard JSON Schema is a ConfigDefinitionError at defineConfig():

Output
defineConfig needs a schema that implements Standard JSON Schema (~standard.jsonSchema), such as Zod 4.2+, ArkType 2.1.28+, or Valibot wrapped with toStandardJsonSchema().

A schema that cannot be converted to JSON Schema (for example a Zod bigint field) fails with Could not convert the config schema to JSON Schema., the library's error attached as cause.

#Testing

Test.createModule() from @knead-js/testing boots the real ConfigModule, which reads process.env. To make a test independent of the machine it runs on, replace the handle's provider with a ready Config:

signup.service.test.ts
test('signups follow the signupsOpen flag', async () => {
  mod = await Test.createModule({
    imports: [ConfigModule.forRoot(AppConfig)],
    providers: [SignupService],
  })
    .overrideProvider(AppConfig)
    .useValue(
      new Config({
        port: 3000,
        databaseUrl: 'postgres://localhost/shop_test',
        jwtSecret: 'test-secret-that-is-at-least-32-chars',
        signupsOpen: true,
      }),
    )
    .compile();

  expect(mod.get(SignupService).isOpen()).toBe(true);
});

Or keep the real loading and validation, and pass the settings as values, which win over the environment:

signup.service.test.ts
test('values run through the schema like environment strings', async () => {
  mod = await Test.createModule({
    imports: [
      ConfigModule.forRoot(AppConfig, {
        values: {
          databaseUrl: 'postgres://localhost/shop_test',
          jwtSecret: 'test-secret-that-is-at-least-32-chars',
          port: '4100',
        },
      }),
    ],
    providers: [SignupService],
  }).compile();

  expect(mod.get(SignupService).port()).toBe(4100);
});

The full API is on the @knead-js/config reference page.