chimera-config
v0.8.0
Published
The easiest, declarative, type-safe, and traceable way to write configurations.
Maintainers
Readme
chimera-config
The easiest, declarative, type-safe, and traceable way to write configurations.
⚠️⚠️ This package is under active development ⚠️⚠️
- chimera-config
- Features
- Non-Features
- Roadmap
- Code Example
- APIs
Features
♻️ Polymorph
Use env files, command line arguments, config files (like JSON or YAML) or write your own.
👮 Type safe
No more any for your configs. All your configs are properly inferred and
validated.
📜 Declarative
Easy to read, easy to write, easy to understand. No surprises where values come from or where they were modified.
🛤️ Trackable
When enabled, track where your configurations come from and store meta data for them.
🔍 Generate config templates
By tracing where your configs come from and adding meta-data, generate example
.env files, default configs, or Markdown tables for documentation. All within
your code.
🍃 No dependencies
Pure JS for a small footprint and overhead.
Non-Features
- 🙅 Handle complex structures, like
docker-compose.yml. - 🙅 Input validation, beyond basic type-checks. However, you can bring your own, like zod (WIP).
- 🙅 Being a sophisticated CLI args parser. This library is meant to set
configs/flags via CLI args. But nothing fancy like the
dockerorawsCLI.
Roadmap
- More supported configs
- JSON (WIP)
- YML
- TOML
- More supported generators
- JSON schema
- YML
- TOML
- Support for validators/transformers
- Standard Schema implementations, like zod (WIP)
- Async configs (like using fetch)
- Consider migrating from TS to pure JS with JSDoc type annotations.
Code Example
import * as c from 'chimera-config';
// Define where to get values from
c.useStores([new c.EnvStore()]);
// Print all paths relative to dirname
c.setRootDir(import.meta.dirname);
// 1️⃣ Define your config
const dbConfig = c.config(
'db', // Prefix of this config
// Define your config as object
{
host: c.string('localhost'),
port: c.port(5432),
auth: {
username: c.string('admin'),
password: c.string(),
},
transaction: {
timeout: c
.integer(5_000)
.with(
c.betweenIncl(1_000, 60_000),
c.description(
'After which time (in milliseconds) transactions are aborted'
)
),
},
}
);
// 2️⃣ Access props of your config with type-safety
const userName: string = dbConfig.auth.username;
console.log(userName);
// 3️⃣ Generate a .env template
console.log(c.generateDotEnvTemplate());This will produce a template for your .env file similar to this:
# This file was generated by running script from-readme.ts:40:15
# db.host at from-readme.ts:12:20
#
# Default: localhost
#DB_HOST=
# db.port at from-readme.ts:12:20
#
# Value must:
# - be an integer
# - be between 0 and 65535 (both inclusive)
# - be a valid IP port
#
# Default: 5432
#DB_PORT=
# db.auth.username at from-readme.ts:12:20
#
# Default: admin
#DB_AUTH_USERNAME=
# db.auth.password at from-readme.ts:12:20
DB_AUTH_PASSWORD=
# db.transaction.timeout at from-readme.ts:12:20
# After which time (in milliseconds) transactions are aborted
#
# Value must:
# - be an integer
# - be between 1000 and 60000 (both inclusive)
#
# Default: 5000
#DB_TRANSACTION_TIMEOUT=APIs
This list of APIs currently manually curated. Help to get this automated/queryable would be greatly appreciated!
Core
c.useStores()
Set the Stores to use by default.
Any config that does not explicitly override the store will use the provided stores.
⚠️ This function should be called before any other c.config() is being
created. That prevents ugly to understand errors (e.g. error is thrown
because config is missing, even though it is present in the process.env) and
race conditions.
To prevent this, split the configuration of chimera-config into its own
file, which is then imported at the very top of you app:
// setup-chimera-config.ts
import * as c from 'chimera-config';
// Set the stores you want to use
c.useStores([new c.EnvStore()]);
// ...set up other chimera-config stuff...// main.ts
import './setup-chimera-config.ts';
// Leave an empty line, so that code-formatters don't move the above import
// ⬇️⬇️⬇️
// ⬆️⬆️⬆️
// Start of your app's code
import * as c from 'chimera-config';
const appConfig = c.config({
port: c.port(3000),
});
const app = new Server();
await app.listen(appConfig.port());c.Store
This basic interface describes the shape of a store.
It's task is to take a ConfigFieldDescriptor and return if the value for this
descriptor was found or not, and if yes, the value of the resolved config.
Example
class LocalStorageStore implements c.Store {
resolve({
path,
}: ConfigFieldDescriptor): [found: boolean, value: string | undefined] {
const key = path.join('.');
const value = localStorage.getItem(key);
return [value != null, value];
}
}c.config()
Defines a new config object from the given spec.
export function config<Spec extends ConfigSpec>(
spec: Spec,
store?: Store,
): ResolvedConfigSpec<Spec>;
export function config<Spec extends ConfigSpec>(
prefix: string,
spec: Spec,
store?: Store,
): ResolvedConfigSpec<Spec>;
export function config<Spec extends ConfigSpec>(
prefix: string[],
spec: Spec,
store?: Store,
): ResolvedConfigSpec<Spec>;Example
const simpleConfig = c.config({
isProduction: c.boolean(),
});
const prefixedConfig = c.config('external-api', {
apiKey: c.string(),
});
const customStore = c.config(
'db',
{
url: c.url(),
},
new EnvStore(),
);Config Parsers
c.ValueParser<Value, ExtraProps = {}>
Container class that defines how a config value should be parsed to a type-safe value.
export class ValueParser<Value, ExtraProps = {}> {
constructor(
public readonly resolve: ValueParserFn<Value>,
public readonly props: ValueParserProps & ExtraProps,
) {}
}Example
const stringArrayParser = new c.ValueParser<string[]>(
({ storeValue }) => {
if (!Array.isArray(storeValue)) {
storeValue = [storeValue];
}
if (!storeValue.every(elem => typeof elem === 'string')) {
throw new Error('Value must be a string array');
}
return storeValue;
}
{}
)c.ValueParser.with
This method allows you to map the value parser to a new ValueParser. You can
modify the attached metadata in the c.ValueParser.props, or update the parsing
behavior by updating the c.ValueParser.resolve.
See Config Modifiers for more information.
c.boolean()
Defines a boolean config value.
export function boolean(
options: BooleanParserOptions | boolean = {},
): ValueParser<
boolean,
{
fallback?: (() => boolean) | undefined;
fallbackDescription?: string | undefined;
primitiveType: BooleanConstructor;
valueMust: string[];
[truthyStringsSymbol]: Set<string>;
[falsyStringsSymbol]: Set<string>;
}
>;c.enumeration()
Define a config value which must be one of the given possible values.
export function enumeration<Value>(
values: Value[],
fallback?: Value,
): ValueParser<
Value,
{
fallback?: (() => NonNullable<Value>) | undefined;
fallbackDescription?: string | undefined;
primitiveType: undefined;
valueMust: string[];
}
>;Example
const appConfig = c.config({
env: c.enumeration(['prod', 'dev', 'local']),
});c.fnParser()
Defines a config value based on a value parser function.
export function fnParser<Fn extends ValueParserFn<any>>(
fn: Fn,
): ValueParser<ReturnType<Fn>, {}>;
export function fnParser<
Fn extends ValueParserFn<any>,
Props extends ValueParserProps,
>(fn: Fn, props: Props): ValueParser<ReturnType<Fn>, Props>;Example
const uniqueId = crypt.randomUUID();
const parsedString = c.fnParser(({ storeValue }) => {
if (typeof storedValue !== 'string') {
throw new Error('Must be a string');
}
// Inject this instance's ID into a config value.
return storedValue.replaceAll('{{ID}}', uniqueId);
});
const exampleConfig = c.config({
add: parsedString,
});c.json()
Define a JSON object config value.
export function json(
fallback?: object,
fallbackDescription?: string,
): ValueParser<
object,
{
fallback?: (() => object) | undefined;
fallbackDescription?: string | undefined;
primitiveType: StringConstructor;
valueMust: string[];
}
>;Example
const loggerConfig = c.config('logging', {
logFormat: c.json(),
});c.number()
Define a number config value.
export function number(fallback?: number): ValueParser<
number,
{
fallback?: (() => number) | undefined;
fallbackDescription?: string | undefined;
primitiveType: NumberConstructor;
valueMust: string[];
}
>;Example
const magicValue = c.config({
factor: c.number();
});c.integer()
Define an integer config value.
export function integer(fallback?: number): ValueParser<
number,
{
fallback?: (() => number) | undefined;
fallbackDescription?: string | undefined;
primitiveType: NumberConstructor;
valueMust: string[];
}
>;Example
const elevatorConfig = c.config({
minFloor: c.integer(),
maxFloor: c.integer(),
});c.bigint()
Define a bigint config value.
export function bigint(fallback?: bigint): ValueParser<
bigint,
{
fallback?: (() => bigint) | undefined;
fallbackDescription?: string | undefined;
primitiveType: BigIntConstructor;
valueMust: string[];
}
>;Example
const myBankAccount = c.config({
cents: c.bigint(),
});c.port()
Defines a port number config value.
export function port(fallback?: number): ValueParser<
number,
{
fallback?: (() => number) | undefined;
fallbackDescription?: string | undefined;
primitiveType: NumberConstructor;
valueMust: string[];
}
>;Example
const appConfig = c.config({
listenPort: c.port(3_000),
});c.string()
Defines a string config value.
export function string(fallback?: string): ValueParser<string>;Example
const apiConfig = c.config({
apiKey: c.string(),
});c.url()
Defines a URL config value.
export function url(fallback?: URL): ValueParser<URL>;Example
const dbConfig = c.config('db', {
url: c.url(),
});Config Modifiers
Config modifiers, essentially, take in a config parser and return a new config parser. This allows you to add more meta data, modify what to do before/after parsing, doing validation of inputs, etc etc.
To use modifiers, you pass them to ValueParser.with.
c.addProps()
Allows you to set arbitrary properties to the current config.
export function addProps<T>(props: T): AddPropsModifier<T>;Example
const appConfig = c.config({
value: c.string().with(
c.addProps({
important: true,
name: 'Value',
}),
),
});c.condition()
Add an condition to a config value.
When this condition is a type-guard, the resulting value will be the guaranteed type.
export function condition<A, B extends A>(
cond: (value: A) => value is B,
msg?: string,
): ValueModifier<A, B>;
export function condition<T>(
cond: (value: T) => boolean,
msg?: string,
): ValueModifier<T, T>;Example
const isEvent = (value: number) => value % 2 === 0;
const appConfig = c.config('app', {
port: c.port().with(c.condition(isEvent)),
});c.description()
Sets the description of the current config.
This description is used by generators to append helpful text to the output.
export function description(
description: string,
): AddPropsModifier<{ description: string }>;Example
const appConfig = c.config('app', {
port: c.port().with(c.description('The port this app listens on.')),
});c.fallback()
Set the fallback to use for the current config.
export function fallback<Fallback>(
fallback: () => Fallback,
): AddPropsModifier<{ fallback: () => Fallback }>;Example
const appConfig = c.config('app', {
port: c.port().with(c.fallback(() => 3_000)),
});c.fallbackDescription()
Use this to add a string description of the fallback value. Usually the string representation of the fallback value itself.
If this is not present, then the stringified version of the passed fallback lambda will be used.
Mostly used internally. Only use if the default fallback stringification is not working for you.
export function fallbackDescription(
hint: string,
): AddPropsModifier<{ fallbackDescription: string }>;Example
const loggingConfig = c.config('db', {
pinoConfig: c.string().with(),
});c.mapProps()
Modifies the properties of the current config via the given mapping function.
export function mapProps<TargetProps>(
propsFn: <SourceProps extends ValueParserProps>(
props: SourceProps,
) => ValueParserProps & TargetProps,
): SetPropsModifier<TargetProps>;Example
const appConfig = c.config('app', {
port: c.port().with(
c.description(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
'Nunc vel diam leo. Mauris eleifend massa sit amet auctor porttitor. ' +
'Donec laoreet felis nec condimentum molestie',
),
c.mapProps((props) => ({
...props,
// Keep the description to a max length of 100
description: props.description.slice(0, 100),
})),
),
});c.map()
Map the value of a config.
export function map<A, B>(mapper: (value: A) => B): ValueModifier<A, B>;Example
const exampleConfig = c.config({
double: c.number().with(c.map((num) => num * 2)),
});c.betweenExcl()
Adds a validator that the config value is between a upper and lower bound (both exclusive).
export function betweenExcl(
a: number,
b: number,
): ValueModifier<number, number>;c.betweenIncl()
Adds a validator that the config value is between a upper and lower bound (both inclusive).
export function betweenIncl(
a: number,
b: number,
): ValueModifier<number, number>;c.lessThan()
Adds a validator that the config value is less than the given value.
export function lessThan(upperBound: number): ValueModifier<number, number>;c.lessThanOrEqual()
Adds a validator that the config value is less than or equal to the given value.
export function lessThanOrEqual(
upperBound: number,
): ValueModifier<number, number>;c.largerThan()
Adds a validator that the config value is larger than the given value.
export function largerThan(upperBound: number): ValueModifier<number, number>;c.largerThanOrEqual()
Adds a validator that the config value is larger than or equal to the given value.
export function largerThanOrEqual(
upperBound: number,
): ValueModifier<number, number>;c.optional()
Make this config optional.
Is an alias for fallback(() => undefined).
See c.fallback().
export function optional();c.valueHint()
Adds a value hint to the current config. This hint will be printed in auto-generated messages, like a .env or the CLI help message.
export function valueHint(
hint: string,
): AddPropsModifier<{ valueMust: string[] }>;Env Store
The EnvStore takes values from environment variables. By default, it will
convert the path of a config value to a environment variable name. For example:
const postgresConfig = c.config(['db', 'postgres'], {
host: c.string(),
port: c.string(),
auth: {
user: c.string(),
password: c.string(),
},
});This will generate the env variable names DB_POSTGRES_HOST,
DB_POSTGRES_PORT, DB_POSTGRES_AUTH_USER, and DB_POSTGRES_AUTH_PASSWORD.
c.EnvStore
export class EnvStore implements Store {
constructor(public readonly options: EnvStoreOptions = {});
}
export interface EnvStoreOptions {
/**
* The prefix to use for all env variables.
*
* @example "MY_APP_"
*/
prefix?: string;
/**
* The env to extract values from.
*
* Defaults to {@link process.env}.
*/
env?: Record<string, unknown>;
/**
* Customize how a config path is transformed to an env var name.
*/
toEnvVariableName?: (path: readonly string[]) => string;
}Generators
c.generateDotEnvTemplate()
Generates a .env template file for the passed config descriptors.
Only config descriptors, that were configured to use an {@link EnvStore} will be included in the template.
export function generateDotEnvTemplate({
descriptors = trackedConfigFieldDescriptors,
rootDir,
header = `# This file was generated by running script ${getCallerLocation().toString(
rootDir,
)}\n`,
}: {
header?: string;
descriptors?: ConfigFieldDescriptor[];
rootDir?: string;
} = {});Modifiers
c.envVarName()
Overrides the environment variable name of the current config.
export function envVarName(
newName: string,
): AddPropsModifier<{ [envVarSymbol]: string }>;Example
const dbConfig = c.config('db', {
url: c.url().with(c.envVarName('DB_FULL_URL')),
});c.envVarAlias()
Add environment variable aliases for the current config.
export function envVarAlias(
...aliases: string[]
): SetPropsModifier<{ [envVarAliasSymbol]: string[] }>;Example
const dbConfig = c.config('db', {
url: c.url().with(c.envVarAlias('POSTGRES_URL', 'SOME_LEGACY_ENV_VAR_NAME')),
});Args Store
The ArgsStore takes values from environment variables. By default, it will
convert the path of a config value to a CLI arg name. For example:
const postgresConfig = c.config(['db', 'postgres'], {
host: c.string(),
port: c.string(),
auth: {
user: c.string(),
password: c.string(),
},
});This will generate the CLI arg names --db-postgres-host, --db-postgres-port,
--db-postgres-auth-user, and --db-postgres-auth-password.
c.ArgsStore
export class ArgsStore implements Store {
constructor(private readonly options: ArgsStoreOptions = {}) {
this.args = options.args ?? process.argv;
}
}
export interface ArgsStoreOptions {
/**
* Prefix to use for all arguments that use this {@link ArgsStore}.
*
* @example "app".
*/
prefix?: string;
/**
* The arguments to parse and extract values from.
*
* Defaults to {@link process.argv}.
*/
args?: readonly string[];
}Generators
c.argsHelpTextBuilder()
Function to auto-generate a help text based on the configs that are configured
with the c.ArgsStore.
export function argsHelpTextBuilder({
descriptors = trackedConfigFieldDescriptors,
intro,
outro,
sort,
maxWidth = process.stdout.isTTY ? process.stdout.columns : Infinity,
sliceLine = sliceLinePreservingWords,
additionalInfo = addConfigMeta,
includeValueHints,
}: ArgsHelpTextBuilderOptions = {}): string;
export interface ArgsHelpTextBuilderOptions {
/**
* Any leading text that comes before the generated help text. Defaults to no
* intro.
*/
intro?: string;
/**
* Any text after the arguments have been printed. Defaults to no outro.
*/
outro?: string;
/**
* The configs to include in the help text.
*
* Only config fields that are configured with the {@link ArgsStore} will be
* processed. All others are filtered out.
*/
descriptors?: ConfigFieldDescriptor[];
/**
* The max line width in characters to limit the help text to.
*
* To disable max width, set to Infinity.
*
* Defaults to {@link process.stdout.columns} if {@link process.stdout.isTTY}
* is true. Null otherwise.
*/
maxWidth?: number;
/**
* Define the order in which the arguments should be included in the help
* text.
*
* If none is provided, the the help text will be generated in the order they
* are in the given {@link ArgsHelpTextBuilderOptions#descriptors} array.
*/
sort?: (a: ArgsWithTexts, b: ArgsWithTexts) => number;
/**
* In case any line (description, intro, outro) is too long to fit into the
* {@link ArgsHelpTextBuilderOptions#maxWidth}, this function will be called
* to split the line into multiple lines which are each no longer than
* maxLength long.
*
* Defaults to breaking the line after the nth word that would make the line
* longer tha max Length characters.
*/
sliceLine?: (descriptionLine: string, maxLength: number) => string[];
/**
* If set to true, all value hints will be included in the generated help
* text. Can be rather verbose, so it's set to false by default.
*/
includeValueHints?: boolean;
/**
* The additional info to print after the description. This text will be
* appended to the description. Returning an empty string is equal to a noop.
*
* Defaults to printing out the env vars overrides for this config.
*/
additionalInfo?: (args: AdditionalInfoArgs) => string;
}Modifiers
c.argAlias()
Add argument aliases for the current config.
If a given alias starts with a dash ("-"), then this alias must be exactly matching. Otherwise the same logic as for other args applies.
export function argAlias(
...aliases: string[]
): SetPropsModifier<{ [argAliasName]: string[] }>;c.argName()
Override the argument name of the current config.
export function argName(
newArgName: string,
): SetPropsModifier<{ [argNameOverride]: string }>;