Anatomy of ConfigBound
Five concepts comprise ConfigBound: the schema, binds, sections, elements, and the ConfigBound instance itself. Understanding each one makes the rest of the API easier to reason about.
The schema
The schema is a plain TypeScript object you pass to ConfigBound.createConfig(). It describes every configuration value your application expects.
Each top-level key is either a configItem (a single value) or a configSection (a named group of items).
const config = await ConfigBound.createConfig({
port: configItem<number>({
default: 3000,
validator: z.number().int().min(1).max(65535),
description: 'The port the server listens on',
example: 8080
}),
database: configSection({
host: configItem<string>({ default: 'localhost', validator: z.hostname() }),
password: configItem<string>({ validator: z.string().min(1), sensitive: true })
})
});Each item carries everything needed to describe and validate that value: its type, a validator, an optional default, an example, a description, and a sensitive flag for masking in logs and exports.
Binds
A bind is a source adapter. It knows how to look up a value by its dot-path key (e.g. database.host) and return whatever it finds from its underlying source—environment variables, a file, a secrets manager, or anything else.
When you call config.get(), ConfigBound iterates the bind list in order and returns the first non-undefined result. This means bind order is meaningful: an EnvVarBind listed before a FileBind takes precedence.
const config = await ConfigBound.createConfig(schema, {
binds: [
new EnvVarBind(), // checked first
new FileBind({ filePath: '.env.local' }) // fallback
]
});If no bind returns a value, ConfigBound falls back to the element's default. If there is no default and the element is required (without .optional()), get() will return undefined and getOrThrow() will throw.
The three built-in binds are EnvVarBind, FileBind, and StaticBind. You can also create your own.
Sections
When createConfig() processes the schema, it builds the internal runtime structure:
- Each
configSectionbecomes a Section by the same name. - Top-level
configItementries (those not inside a section) are grouped into an implicit section calledapp. - Each item within a section becomes an Element.
This is why config.get() always takes two arguments: a section name and an element name.
const port = await config.get('app', 'port'); // top-level items → 'app' section
const host = await config.get('database', 'host'); // named sections use their own nameElements
An element is the unit of runtime behavior for a single configuration value.
Default validation. When an element is constructed, its default value (if provided) is immediately validated against the Zod schema. This means a bad default is caught at startup, not when the value is first read.
Sensitive masking. Setting sensitive: true on a configItem causes the element to be masked in log output and excluded from plaintext exports. Use this for passwords, tokens, and any other secrets.
Schema export control. Setting omitFromSchema: true excludes the element from generated schema exports (e.g. the output of configbound export). This is useful for internal or derived values that consumers of the config schema do not need to know about.
Required detection. An element knows whether it is required by checking if the Zod validator is optional. This is how validate() and validateOnInit determine which missing values should cause an error.
Value retrieval. When you call config.get(section, element), the element delegates to the active bind list via its value provider, returns the first non-undefined result, validates it, and falls back to its default if nothing was found. getOrThrow follows the same path but throws a ConfigUnsetException instead of returning undefined.
The ConfigBound instance
createConfig() returns a TypedConfigBound, which wraps a ConfigBound and provides full TypeScript inference over the schema. The two main ways to read values are:
get(section, element)- returns the value orundefinedif nothing is set and there is no default.getOrThrow(section, element)- returns the value or throws if it isundefined.
Validation runs when a value is retrieved: the value from the bind is passed through the Zod validator before being returned. You can also validate all values upfront at startup using validate() or by passing validateOnInit: true to createConfig():
const config = await ConfigBound.createConfig(
{ port: configItem<number>({ validator: z.number().int().min(1).max(65535) }) },
{ validateOnInit: true }
);This causes createConfig() to check every element immediately and throw if any required value is missing or invalid, so configuration problems surface at startup rather than at first use.