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:zodnpm add @optique/config @standard-schema/spec zodpnpm add @optique/config @standard-schema/spec zodyarn add @optique/config @standard-schema/spec zodbun add @optique/config @standard-schema/spec zodWhy 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 localhostThe result will be:
host:"localhost"(from CLI, overrides config)port:8080(from config file)
Priority order
Values are resolved in this priority order:
- CLI argument: Highest priority, always used when provided
- Config file value: Used when CLI argument not provided
- Default value: Used when neither CLI nor config provides a value
- 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.shThe 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=8080Multi-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 fileoptions.fileParser: Optional custom parser for file contents (defaults toJSON.parse)
- Returns
ConfigContext<T, TConfigMeta>implementingSourceContextinterface
bindConfig(parser, options)
Binds a parser to configuration values with fallback priority.
- Parameters
parser: The parser to bindoptions.context: Config context to useoptions.key: Property key or accessor function to extract value from config. Accessor functions receive two arguments:config: validated config datameta: config metadata if available (ConfigMeta | undefinedby 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
loadcallback. load- Function that receives parsed result and returns
ConfigLoadResult<TConfigMeta>(or Promise of it).metamay beundefined. Use this for multi-file merging scenarios. Optional when usinggetConfigPath.
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 fileconfigDir: Directory containing the config file
Limitations
- File I/O is async — config loading always returns a Promise due to file reading, so use
runAsync()orrun()(which returns a Promise when contexts are provided) - JSON only by default — Other formats require the
fileParseroption oncreateConfigContext()or a customloadcallback - 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 --verboseThe @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.