Skip to main content

Schema API

Define your configuration shape using factory functions. Each field accepts options like required, env, cli, defaultValue, sensitive, oneOf, and validate.

Primitives

c.string({
required: true,
env: "MY_VAR",
cli: true,
defaultValue: "fallback",
});
c.number({ required: true, env: "PORT" });
c.bool({ env: "DEBUG", defaultValue: false });

All factory functions can be called with no arguments for optional fields with no special behavior:

c.string(); // optional string, no env/cli mapping
c.number(); // optional number
c.bool(); // optional boolean

Objects

Use c.object() to declare nested object schemas. Fields must be inside the item property:

c.object({
item: {
host: c.string(),
port: c.number(),
},
});
tip

A common mistake is passing fields directly to c.object() instead of wrapping them in item:

// WRONG — fields are passed directly
c.object({ host: c.string(), port: c.number() });

// CORRECT — fields must be inside `item`
c.object({ item: { host: c.string(), port: c.number() } });

Objects can be nested arbitrarily deep:

c.schema({
database: c.object({
item: {
host: c.string(),
port: c.number(),
credentials: c.object({
item: {
username: c.string(),
password: c.string({ env: "DB_PASSWORD" }),
},
}),
},
}),
});

c.object() accepts a required option (defaults to false). When the entire subtree is absent from all sources, child required options will trigger errors through normal validation.

Arrays

c.array({ required: true, item: c.string() }); // string[]
c.array({ required: true, item: c.number() }); // number[]
c.array({
item: c.object({
item: { name: c.string(), age: c.number() },
}),
}); // { name: string; age: number }[]

Enum Constraints (oneOf)

Use oneOf to restrict a field to a fixed set of allowed values. The check runs after type coercion and before any validate schema:

const config = c
.schema({
env: c.string({
env: "NODE_ENV",
defaultValue: "development",
oneOf: ["development", "staging", "production"],
}),
logLevel: c.number({
env: "LOG_LEVEL",
defaultValue: 1,
oneOf: [0, 1, 2, 3],
}),
})
.load({ env: true, args: false });

If a value is not in the allowed set, a ConfigLoadError is thrown with kind: "validation".

Type Narrowing

When oneOf is provided, the inferred type is automatically narrowed to the union of the allowed values:

const config = c
.schema({
env: c.string({ oneOf: ["dev", "staging", "prod"] }),
})
.load({ env: false, args: false });

// config.env is typed as "dev" | "staging" | "prod", not string

When used with cli: true, the --help output automatically lists the allowed values.

Sensitive Fields

Mark fields as sensitive: true to prevent their values from being exposed in logs or debug output:

const schema = {
host: c.string({ defaultValue: "localhost" }),
apiKey: c.string({ env: "API_KEY", sensitive: true }),
db: c.object({
item: {
host: c.string({ defaultValue: "db.local" }),
password: c.string({ env: "DB_PASS", sensitive: true }),
},
}),
};

const config = c.schema(schema).load({ env: true, args: false });

Sensitive values load normally — config.apiKey returns the real value. The flag only affects the masking utilities printConfig() and maskSecrets().

Validation

Add per-field validation using the validate option. config-loader accepts any Standard Schema v1 implementation — including Zod, Valibot, and ArkType — or a custom validator.

Validation runs after type coercion, so validators see the final typed value (e.g., the number 3000, not the string "3000" from an env var).

With Zod

import c from "@meltstudio/config-loader";
import { z } from "zod";

const config = c
.schema({
port: c.number({
required: true,
env: "PORT",
validate: z.number().min(1).max(65535),
}),
host: c.string({
required: true,
validate: z.string().url(),
}),
env: c.string({
defaultValue: "development",
validate: z.enum(["development", "staging", "production"]),
}),
})
.load({ env: true, args: false, files: "./config.yaml" });

With a custom validator

Any object with a ~standard.validate() method works:

const portValidator = {
"~standard": {
version: 1,
vendor: "my-app",
validate(value: unknown) {
if (typeof value === "number" && value >= 1 && value <= 65535) {
return { value };
}
return { issues: [{ message: "must be a valid port (1-65535)" }] };
},
},
};

c.number({ required: true, env: "PORT", validate: portValidator });

Validation pipeline

The full validation pipeline for each field is: resolve value → type coerce → oneOf check → validate schema. If oneOf fails, validate is skipped. If type coercion fails, both oneOf and validate are skipped.