Skip to main content

Configuration

Configuration

Exposing configuration is of fundamental importance to ensure the versatility of your application, but ensuring that said configuration comes in an expected format is also very important, you wouldn't want your app to still run and behave incorrectly would you?

npm i @basica/config zod

Basica validates configuration using any Standard Schema library. The examples below use Zod.

service.ts
import { setTimeout } from "node:timers/promises"

export type Config = {
delay: number
}

export class Service {

constructor(private readonly config: Config) {}

async delay() {
console.log(`Waiting ${this.config.delay}ms`)
await setTimeout(this.config.delay)
console.log("Ok!")
}
}
config.ts
import { z } from "zod";

export const schema = z.object({
service: z.object({
delay: z.number().min(500).max(5000).default(1000),
}),
});

Environment variables

Using the envProvider, we can read configuration from environment variables or a .env file into our app. By default, nested properties are concatenated with an underscore and variables are uppercase.

.env
SERVICE_DELAY=3000
note

When reading from environment variables, the schema must also expose a Standard JSON Schema (e.g. zod v4).

note

Environment variables arrive as strings, but the envProvider coerces each value to the type declared in the schema, so plain z.number() / z.boolean() work.

note

Records and arrays are parsed as json content.

const schema = z.object({
// Variable WORKING_VALUE will work
working: z.object({
value: z.string(),
}),

// Variable ONE_PROP won't work, use ONE='{ "prop": "..." }' instead
one: z.record(z.string(), z.string()),

// Variable TWO_0 won't work, use TWO='["..."]' instead
two: z.array(z.string()),
});

const config = configure(envProvider(), schema);
note

Unions and intersections are also supported.

const schema = z.object({
test: z.intersection(
z.union([
z.object({
// Variable TEST_A
a: z.string(),
// Either TEST_D or TEST_D_VALUE (TEST_D takes precedence)
d: z.union([z.string(), z.object({ value: z.string() })]),
}),
z.object({
// Variable TEST_B
b: z.string(),
}),
]),
z.object({
// Variable TEST_C
c: z.string(),
})
),
});

const config = configure(envProvider(), schema);
note

Pass dotenv: false to skip loading a .env file entirely (e.g. in production, where the environment is already populated).

const config = configure(envProvider({ dotenv: false }), schema);
index.ts
import { configure, envProvider } from "@basica/config"

import { Service } from "./service"
import { schema as configSchema } from "./config"

const config = configure(envProvider(), configSchema)

const service = new Service(config.service)

await service.delay()
Waiting 3000ms
Ok!

Bring your own provider

envProvider is just one implementation of ConfigProvider

type ConfigProvider<S = unknown> = {
get(schema: S): Record<string, unknown>;
};

configure validates whatever the provider returns against your schema. A provider that already produces a fully-shaped object (reading a static object, a JSON/YAML/TOML file, a remote config service) doesn't need to look at the schema at all:

Reading config from a JSON file
import { readFileSync } from "node:fs";
import { ConfigProvider } from "@basica/config";

const jsonFileProvider = (path: string) => ({
get: () => JSON.parse(readFileSync(path, "utf-8")),
} satisfies ConfigProvider);

Only providers that read from a flat source (like envProvider, or a Consul/SSM-style key-value store) need to introspect the schema to know which keys to look for. That's why they require a schema that also exposes a JSON Schema, while a shaped provider works with any Standard Schema.

Choosing a schema library

Because configure accepts any Standard Schema, you can author configuration with Valibot, ArkType, Effect, and others. Two things to keep in mind.

note

envProvider needs a library compatible with Standard JSON Schema.

import * as v from "valibot";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { configure, envProvider } from "@basica/config";

const schema = toStandardJsonSchema(v.object({ port: v.number() }));

const config = configure(envProvider(), schema);
note

You can only reuse the schemas Basica provides (loggerConfigSchema, pgConfigSchema, etc.) if you author config with Zod. With another library you have to reimplement the shape yourself to match Basica's exported types:

import * as v from "valibot";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { z } from "zod";
import { configure, envProvider } from "@basica/config";
import { loggerFactory, loggerConfigSchema } from "@basica/core/logger";

const schema = toStandardJsonSchema(
v.object({
// loggerConfigSchema is a Zod schema: reimplement its shape with valibot
logger: v.object({
level: v.optional(
v.picklist(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
),
}),
})
);

const config = configure(envProvider(), schema);

// your reimplementation must conform to Basica's exported schema type
config.logger satisfies z.infer<typeof loggerConfigSchema>;

const logger = loggerFactory(config.logger);

API Docs

Find the api docs on jsdocs.io