Config@rhythmjs/config
Defining configuration
Config factories that own their own Standard Schema validation - defineConfig for the root, registerAs for namespaces, ConfigError for honest failures.
Install the package#
@rhythmjs/config is a NestJS-style configuration module for the Rhythm kernel. Everything is exported from the package root; the type helpers are also available on the /types subpath. Schemas are Standard Schema v1, so zod, valibot, and arktype all work. This page covers defining and validating configuration; the config service covers registering it and reading it back.
bun add @rhythmjs/config @rhythmjs/rhythm zodConfig files own their schemas#
defineConfig(schema, factory) pairs a schema with the factory that reads the environment: each config file validates and coerces itself, NestJS-style. The returned function is an async factory that runs the schema every time it is called and throws a ConfigError with serialized issues when validation fails. Coercions and defaults live in the schema, so process.env strings become numbers and booleans before anyone reads them, and Bun loads .env files natively, no loader needed.
import { defineConfig } from "@rhythmjs/config";
import { z } from "zod";
export const appConfig = defineConfig(
z.object({
port: z.coerce.number().default(3000),
debug: z.coerce.boolean().default(false),
}),
() => ({ port: process.env.PORT, debug: process.env.DEBUG }),
);Namespace with registerAs#
registerAs(token, schema, factory) builds the same kind of self-validating factory but tags it with a namespace: its output lands under token in the merged tree, and validation issues carry the token prefixed to their paths (database.port, not port), so aggregated boot errors stay readable. The two-argument form registerAs(token, factory) skips validation for values that are already trusted. Factories may be async: fetching remote configuration at startup works.
import { registerAs } from "@rhythmjs/config";
import { z } from "zod";
export const databaseConfig = registerAs(
"database",
z.object({
host: z.string().default("localhost"),
port: z.coerce.number().default(5432),
}),
() => ({ host: process.env.DATABASE_HOST, port: process.env.DATABASE_PORT }),
);
const remoteConfig = registerAs("remote", async () => fetchSettings());ConfigError#
Every validation failure surfaces as a ConfigError. Its issues array holds { message, path? } entries serialized from the schema library's Standard Schema issues (symbol path segments are dropped, object segments reduced to their keys), and its message lists every issue with its dot path, one line per problem. createConfigService(value) is also exported directly for building a service from an already-validated object, without the module.
import { ConfigError, configModule } from "@rhythmjs/config";
try {
const config = await configModule.forRoot(appConfig, databaseConfig); // loads and validates at startup
app.register(config, ({ configService }) => ({ configService }));
} catch (error) {
if (error instanceof ConfigError) {
for (const issue of error.issues) report(issue.path?.join("."), issue.message);
}
throw error;
}