Skip to content

Config file support

This API is available since Optique 0.10.0.

The @optique/config package provides configuration file support for Optique, enabling CLI applications to load default values from configuration files with proper priority handling: CLI arguments > config file values > defaults.

deno add jsr:@optique/config npm:@standard-schema/spec npm:zod
npm add @optique/config @standard-schema/spec zod
pnpm add @optique/config @standard-schema/spec zod
yarn add @optique/config @standard-schema/spec zod
bun add @optique/config @standard-schema/spec zod

Why config files?

Many CLI applications need configuration files for:

  • Default values that persist across invocations
  • Environment-specific settings (development, staging, production)
  • Complex options that are tedious to specify on the command line
  • Shared settings across team members (via version control)

The @optique/config package handles this pattern with full type safety, automatic validation, and seamless integration with Optique parsers.

Basic usage

1. Create a config context

Define your configuration schema using any Standard Schema-compatible library:

import { 
z
} from "zod";
import {
createConfigContext
} from "@optique/config";
const
configSchema
=
z
.
object
({
host
:
z
.
string
(),
port
:
z
.
number
(),
verbose
:
z
.
boolean
().
optional
(),
}); const
configContext
=
createConfigContext
({
schema
:
configSchema
});

2. Bind parsers to config values

Use bindConfig() to create parsers that fall back to configuration file values:

import { 
option
} from "@optique/core/primitives";
import {
string
,
integer
} from "@optique/core/valueparser";
const
hostParser
=
bindConfig
(
option
("--host",
string
()), {
context
:
configContext
,
key
: "host",
default
: "localhost",
}); const
portParser
=
bindConfig
(
option
("--port",
integer
()), {
context
:
configContext
,
key
: "port",
default
: 3000,
});

3. Run with config support

Pass the config context to runAsync() (or run()) via the contexts option:

import { 
runAsync
} from "@optique/run";
const
result
= await
runAsync
(
parser
, {
contexts
: [
configContext
],
getConfigPath
: (
parsed
) =>
parsed
.
config
,
});
console
.
log
(`Connecting to ${
result
.
host
}:${
result
.
port
}`);

If the config file ~/.myapp.json contains:

{
  "host": "api.example.com",
  "port": 8080
}

And the user runs:

myapp --host localhost

The result will be:

  • host: "localhost" (from CLI, overrides config)
  • port: 8080 (from config file)

Priority order

Values are resolved in this priority order:

  1. CLI argument: Highest priority, always used when provided
  2. Config file value: Used when CLI argument not provided
  3. Default value: Used when neither CLI nor config provides a value
  4. Error: If no value is available and no default is specified
import { 
option
} from "@optique/core/primitives";
import {
integer
} from "@optique/core/valueparser";
// With default: always succeeds const
portWithDefault
=
bindConfig
(
option
("--port",
integer
()), {
context
:
configContext
,
key
: "port",
default
: 3000,
}); // Without default: requires CLI or config const
portRequired
=
bindConfig
(
option
("--port",
integer
()), {
context
:
configContext
,
key
: "port",
// No default - will error if not in CLI or config });

Help, version, and completion

When using run() or runAsync() with config contexts, help messages, version display, and shell completion generation all work seamlessly. These features work even when configuration files are missing or invalid, ensuring users can always access help:

import { 
runAsync
} from "@optique/run";
const
result
= await
runAsync
(
parser
, {
contexts
: [
configContext
],
getConfigPath
: (
parsed
) =>
parsed
.
config
,
help
: "option",
version
: "1.0.0",
completion
: "option",
});

Now users can use:

# Show help (even if config file is missing)
myapp --help

# Show version
myapp --version

# Generate shell completion
myapp --completion bash > myapp-completion.sh

The key benefit is that help, version, and completion work before config file loading, so they succeed even when the config file is invalid or missing.

Nested config values

Use accessor functions to extract nested configuration values:

import { 
z
} from "zod";
import {
createConfigContext
,
bindConfig
} from "@optique/config";
import {
option
} from "@optique/core/primitives";
import {
string
} from "@optique/core/valueparser";
const
configSchema
=
z
.
object
({
server
:
z
.
object
({
host
:
z
.
string
(),
port
:
z
.
number
(),
}),
database
:
z
.
object
({
host
:
z
.
string
(),
port
:
z
.
number
(),
}), }); const
configContext
=
createConfigContext
({
schema
:
configSchema
});
const
serverHost
=
bindConfig
(
option
("--server-host",
string
()), {
context
:
configContext
,
key
: (
config
) =>
config
.
server
.
host
,
default
: "localhost",
}); const
dbHost
=
bindConfig
(
option
("--db-host",
string
()), {
context
:
configContext
,
key
: (
config
) =>
config
.
database
.
host
,
default
: "localhost",
});

With a config file:

{
  "server": {
    "host": "api.example.com",
    "port": 8080
  },
  "database": {
    "host": "db.example.com",
    "port": 5432
  }
}

Resolving paths relative to config files

For path-like options, CLI values and config values often need different base directories:

  • CLI values are usually interpreted relative to the current working directory
  • Config values are usually interpreted relative to the config file location

bindConfig() key callbacks receive metadata as a second argument, so you can resolve config-relative paths reliably.

import { 
resolve
} from "node:path";
import {
z
} from "zod";
import {
bindConfig
,
createConfigContext
} from "@optique/config";
import {
option
} from "@optique/core/primitives";
import {
string
} from "@optique/core/valueparser";
import {
map
} from "@optique/core/modifiers";
const
configContext
=
createConfigContext
({
schema
:
z
.
object
({
outDir
:
z
.
string
(),
}), }); const
parser
=
bindConfig
(
map
(
option
("--out-dir",
string
()), (
value
) =>
resolve
(
process
.
cwd
(),
value
)),
{
context
:
configContext
,
key
: (
config
,
meta
) => {
if (
meta
===
undefined
) {
throw new
TypeError
("Config metadata is not available.");
} return
resolve
(
meta
.
configDir
,
config
.
outDir
);
}, }, );

In single-file mode, Optique provides meta.configPath and meta.configDir automatically, so the guard above only matters when metadata may be absent.

Config-only values

Sometimes a configuration value should never come from a CLI flag—it lives entirely in the config file (or uses a default). In that case, use fail<T>() as the inner parser for bindConfig().

fail<T>() always fails, so bindConfig() always falls back to the config file or the supplied default. Compare this with constant(value), which always succeeds and would prevent the config fallback from ever triggering.

import { 
z
} from "zod";
import {
bindConfig
,
createConfigContext
} from "@optique/config";
import {
object
} from "@optique/core/constructs";
import {
fail
,
option
} from "@optique/core/primitives";
import {
integer
,
string
} from "@optique/core/valueparser";
import {
withDefault
} from "@optique/core/modifiers";
import {
runAsync
} from "@optique/run";
const
configSchema
=
z
.
object
({
host
:
z
.
string
(),
port
:
z
.
number
(),
// timeout only lives in the config file, not exposed as a CLI flag
timeout
:
z
.
number
().
optional
(),
}); const
configContext
=
createConfigContext
({
schema
:
configSchema
});
const
parser
=
object
({
config
:
withDefault
(
option
("--config",
string
()), "~/.myapp.json"),
host
:
bindConfig
(
option
("--host",
string
()), {
context
:
configContext
,
key
: "host",
default
: "localhost",
}),
port
:
bindConfig
(
option
("--port",
integer
()), {
context
:
configContext
,
key
: "port",
default
: 3000,
}), // No CLI flag — value comes only from config file or default
timeout
:
bindConfig
(
fail
<number>(), {
context
:
configContext
,
key
: "timeout",
default
: 30,
}), }); const
result
= await
runAsync
(
parser
, {
contexts
: [
configContext
],
getConfigPath
: (
parsed
) =>
parsed
.
config
,
});
console
.
log
(`Timeout: ${
result
.
timeout
}s`);

With a config file containing "timeout": 60, result.timeout will be 60. Without a config file (or if timeout is absent), it falls back to 30.

Custom file formats

By default, @optique/config parses JSON files. You can provide a custom file parser when creating the config context:

import { 
z
} from "zod";
import {
createConfigContext
,
bindConfig
} from "@optique/config";
import {
object
} from "@optique/core/constructs";
import {
option
} from "@optique/core/primitives";
import {
string
,
integer
} from "@optique/core/valueparser";
import {
runAsync
} from "@optique/run";
const
configSchema
=
z
.
object
({
host
:
z
.
string
(),
port
:
z
.
number
(),
}); // Custom parser for KEY=VALUE format const
customParser
= (
contents
:
Uint8Array
): unknown => {
const
text
= new
TextDecoder
().
decode
(
contents
);
const
lines
=
text
.
split
("\n");
const
result
:
Record
<string, string | number> = {};
for (const
line
of
lines
) {
const [
key
,
value
] =
line
.
split
("=");
if (
key
&&
value
) {
result
[
key
] =
key
=== "port" ?
parseInt
(
value
, 10) :
value
;
} } return
result
;
}; // Pass fileParser to createConfigContext const
configContext
=
createConfigContext
({
schema
:
configSchema
,
fileParser
:
customParser
,
}); const
parser
=
object
({
config
:
option
("--config",
string
()),
host
:
bindConfig
(
option
("--host",
string
()), {
context
:
configContext
,
key
: "host",
default
: "localhost",
}),
port
:
bindConfig
(
option
("--port",
integer
()), {
context
:
configContext
,
key
: "port",
default
: 3000,
}), }); const
result
= await
runAsync
(
parser
, {
contexts
: [
configContext
],
getConfigPath
: (
parsed
) =>
parsed
.
config
,
});

Now your application can read files in the custom KEY=VALUE format:

host=api.example.com
port=8080

Multi-file configuration

For advanced scenarios like hierarchical config merging (system → user → project), use the load callback in the runtime options:

import { 
z
} from "zod";
import {
createConfigContext
,
bindConfig
} from "@optique/config";
import {
object
} from "@optique/core/constructs";
import {
option
} from "@optique/core/primitives";
import {
string
,
integer
} from "@optique/core/valueparser";
import {
runAsync
} from "@optique/run";
import {
readFile
} from "node:fs/promises";
import {
dirname
,
resolve
} from "node:path";
declare function
deepMerge
(...
objects
: any[]): any;
const
configSchema
=
z
.
object
({
host
:
z
.
string
(),
port
:
z
.
number
(),
timeout
:
z
.
number
().
optional
(),
}); const
configContext
=
createConfigContext
({
schema
:
configSchema
});
const
parser
=
object
({
config
:
option
("--config",
string
()).optional(),
host
:
bindConfig
(
option
("--host",
string
()), {
context
:
configContext
,
key
: "host",
default
: "localhost",
}),
port
:
bindConfig
(
option
("--port",
integer
()), {
context
:
configContext
,
key
: "port",
default
: 3000,
}), }); const
result
= await
runAsync
(
parser
, {
contexts
: [
configContext
],
load
: async (
parsed
) => {
// Load multiple config files with different error handling const
tryLoad
= async (
path
: string) => {
try { return
JSON
.
parse
(await
readFile
(
path
, "utf-8"));
} catch { return {}; // Silent skip on error } }; const
system
= await
tryLoad
("/etc/myapp/config.json");
const
user
= await
tryLoad
(`${
process
.
env
.
HOME
}/.config/myapp/config.json`);
const
project
= await
tryLoad
("./.myapp.json");
// Load custom config file if specified (throws on error) const
custom
=
parsed
.
config
?
JSON
.
parse
(await
readFile
(
parsed
.
config
, "utf-8"))
: {}; const
customPath
=
resolve
(
parsed
.
config
?? "./.myapp.json");
// Merge with priority: custom > project > user > system return {
config
:
deepMerge
(
system
,
user
,
project
,
custom
),
meta
: {
configPath
:
customPath
,
configDir
:
dirname
(
customPath
),
}, }; }, });

This approach gives you full control over:

  • File discovery and loading order
  • Error handling policies (silent skip vs. hard error)
  • Merging strategies (deep merge, shallow merge, array concatenation, etc.)
  • File formats (JSON, TOML, YAML, etc.)

You'll need to provide your own merge utility (e.g., from lodash or es-toolkit).

Standard Schema support

The @optique/config package uses Standard Schema, which means it works with any compatible validation library:

Zod

import { 
z
} from "zod";
import {
createConfigContext
} from "@optique/config";
const
configContext
=
createConfigContext
({
schema
:
z
.
object
({
apiKey
:
z
.
string
().
min
(32),
timeout
:
z
.
number
().
positive
(),
}), });

Valibot

import * as 
v
from "valibot";
import {
createConfigContext
} from "@optique/config";
const
configContext
=
createConfigContext
({
schema
:
v
.
object
({
apiKey
:
v
.
pipe
(
v
.
string
(),
v
.
minLength
(32)),
timeout
:
v
.
pipe
(
v
.
number
(),
v
.
minValue
(1)),
}), });

ArkType

import { 
type
} from "arktype";
import {
createConfigContext
} from "@optique/config";
const
configContext
=
createConfigContext
({
schema
:
type
({
apiKey
: "string>=32",
timeout
: "number>0",
}), });

Composable with other sources

Config contexts implement the SourceContext interface, allowing composition with other data sources. When using run() or runAsync() with multiple contexts, you can pass them all in the contexts array. Earlier contexts override later ones, enabling natural priority chains like CLI > environment variables > config file > defaults:

import { 
runAsync
} from "@optique/run";
// Combine config with other sources (e.g., environment variables) const
result
= await
runAsync
(
parser
, {
contexts
: [
configContext
],
getConfigPath
: (
parsed
) =>
parsed
.
config
, // Typed from parser result!
});

The getConfigPath callback is fully typed based on the parser's result type, providing type safety without manual type assertions.

You can also use runWith() from @optique/core/facade directly for process-agnostic environments:

import { 
runWith
} from "@optique/core/facade";
const
result
= await
runWith
(
parser
, "myapp", [
configContext
], {
args
:
process
.
argv
.
slice
(2),
getConfigPath
: (
parsed
) =>
parsed
.
config
,
});

Error handling

Config file not found

If the config file is not found, @optique/config continues with default values:

import { 
z
} from "zod";
import {
createConfigContext
,
bindConfig
} from "@optique/config";
import {
object
} from "@optique/core/constructs";
import {
option
} from "@optique/core/primitives";
import {
string
} from "@optique/core/valueparser";
import {
runAsync
} from "@optique/run";
const
configContext
=
createConfigContext
({
schema
:
z
.
object
({
host
:
z
.
string
() }),
}); const
parser
=
object
({
config
:
option
("--config",
string
()),
host
:
bindConfig
(
option
("--host",
string
()), {
context
:
configContext
,
key
: "host",
default
: "localhost",
}), }); // Config file not found or not specified - uses default const
result
= await
runAsync
(
parser
, {
contexts
: [
configContext
],
getConfigPath
: (
parsed
) =>
parsed
.
config
,
args
: [],
});
console
.
log
(
result
.
host
); // "localhost" (default)

Invalid config file

If the config file fails validation, an error is thrown:

import { 
runAsync
} from "@optique/run";
try { const
result
= await
runAsync
(
parser
, {
contexts
: [
configContext
],
getConfigPath
: (
parsed
) => "/path/to/invalid-config.json",
args
: [],
}); } catch (
error
) {
console
.
error
("Config validation failed:",
error
);
}

API reference

createConfigContext(options)

Creates a configuration context.

Parameters
  • options.schema: Standard Schema validator for the config file
  • options.fileParser: Optional custom parser for file contents (defaults to JSON.parse)
Returns
ConfigContext<T, TConfigMeta> implementing SourceContext interface

bindConfig(parser, options)

Binds a parser to configuration values with fallback priority.

Parameters
  • parser: The parser to bind

  • options.context: Config context to use

  • options.key: Property key or accessor function to extract value from config. Accessor functions receive two arguments:

    1. config: validated config data
    2. meta: config metadata if available (ConfigMeta | undefined by default)
  • options.default: Optional default value

Returns
A new parser with config fallback behavior

Runtime options

When using a config context with run(), runAsync(), or runWith(), the following context-specific options are passed alongside the standard runner options:

getConfigPath
Function to extract config file path from parsed result. Optional when using the load callback.
load
Function that receives parsed result and returns ConfigLoadResult<TConfigMeta> (or Promise of it). meta may be undefined. Use this for multi-file merging scenarios. Optional when using getConfigPath.

At least one of getConfigPath or load must be provided.

configKey

Symbol key used to store config data in annotations.

configMetaKey

Symbol key used to store config metadata in annotations.

ConfigMeta

Default config metadata shape:

  • configPath: Absolute path to the config file
  • configDir: Directory containing the config file

Limitations

  • File I/O is async — config loading always returns a Promise due to file reading, so use runAsync() or run() (which returns a Promise when contexts are provided)
  • JSON only by default — Other formats require the fileParser option on createConfigContext() or a custom load callback
  • Two-pass parsing — Parsing happens twice (once to extract config path, once with config data), which has a performance cost
  • Standard Schema required — You must use a Standard Schema-compatible validation library
  • No built-in merge utilities — Multi-file merging requires bringing your own merge function (e.g., from lodash or es-toolkit)

Example application

Here's a complete example of a CLI application with config file support:

import { 
z
} from "zod";
import {
createConfigContext
,
bindConfig
} from "@optique/config";
import {
object
} from "@optique/core/constructs";
import {
option
,
flag
} from "@optique/core/primitives";
import {
string
,
integer
} from "@optique/core/valueparser";
import {
withDefault
} from "@optique/core/modifiers";
import {
runAsync
} from "@optique/run";
// Define config schema const
configSchema
=
z
.
object
({
host
:
z
.
string
(),
port
:
z
.
number
(),
verbose
:
z
.
boolean
().
optional
(),
apiKey
:
z
.
string
(),
}); const
configContext
=
createConfigContext
({
schema
:
configSchema
});
// Build parser const
parser
=
object
({
config
:
withDefault
(
option
("--config",
string
()), "~/.myapp.json"),
host
:
bindConfig
(
option
("--host",
string
()), {
context
:
configContext
,
key
: "host",
default
: "localhost",
}),
port
:
bindConfig
(
option
("--port",
integer
()), {
context
:
configContext
,
key
: "port",
default
: 3000,
}),
verbose
:
bindConfig
(
flag
("--verbose"), {
context
:
configContext
,
key
: "verbose",
default
: false,
}),
apiKey
:
bindConfig
(
option
("--api-key",
string
()), {
context
:
configContext
,
key
: "apiKey",
// No default - required from CLI or config }), }); // Run with config support const
config
= await
runAsync
(
parser
, {
contexts
: [
configContext
],
getConfigPath
: (
parsed
) =>
parsed
.
config
,
}); if (
config
.
verbose
) {
console
.
log
("Configuration:",
config
);
} // Use the configuration
console
.
log
(`Connecting to ${
config
.
host
}:${
config
.
port
}`);
console
.
log
(`API Key: ${
config
.
apiKey
.
substring
(0, 8)}...`);

With a config file ~/.myapp.json:

{
  "host": "api.example.com",
  "port": 8080,
  "apiKey": "secret-key-12345678"
}

Running the application:

# Uses all config values
myapp

# Override host from CLI
myapp --host localhost

# Enable verbose mode
myapp --verbose

The @optique/config package provides a clean, type-safe way to manage configuration files in your CLI applications while maintaining the flexibility of command-line arguments.