@btravstack/config
Reference. A complete, structured description of
@btravstack/config: theEnvport, the field constructors,Config.object,Config.parse,Config.providerand the errors they answer. For the task, see Configure from the environment; for the generated signatures, see the API reference.
@btravstack/config peers on @btravstack/di and unthrown and depends on nothing else. Every export is named below.
Env and Environment
type Environment = Readonly<Record<string, string | undefined>>;
class Env extends Port("Env")<Environment> {}Env is the process environment as a port. @btravstack/core's start provides it to every graph it boots — process.env by default, StartOptions.env for a test — the same way it discharges Scope, which is why start accepts Module<X, E, Scope | Env>. Outside the kernel (a bare Module.scoped), provide it yourself: Provider(Env)({ inject: {}, value: process.env }). A module that provides Env itself is booted without the kernel's copy, and its own wins.
Fields
A field reads one variable into a typed value:
type ConfigField<
T,
V extends string = string,
Need extends "required" | "optional" = "required" | "optional",
> = {
readonly variable: V;
readonly parse: (raw: string | undefined) => Result<T, ConfigFieldInvalid>;
/** The same rule over a value that is already a `T` — a pin, or a default. Optional. */
readonly check?: (value: T) => Result<T, ConfigFieldInvalid>;
/** Type-only: whether the variable must be set. Never present at run time. */
readonly "~need"?: Need;
};A field carries its variable's name and whether it must be set, in its type. Config.string("DATABASE_URL") is a ConfigField<string, "DATABASE_URL", "required">; with a default it is "optional"; a field typed ConfigField<T, "NAME"> with no need given counts as required. Config.pinned(value, field) reads nothing when value is certainly there, and leaves the variable "optional" when value may be undefined. A starter whose options pin variables carries the same rule in its own type: it infers the options as written, checks them key by key with OptionsAs<O, Options> (so a misspelt or mistyped option is still refused at its key), and spells its needs with Unpinned<O, Key, Variable> and MaybePinned<O, Key, Variable> — required when O leaves the key out, optional when the key is optional or its value may be undefined, absent when it is certainly set. So an unpinned HTTP_JWT_ISSUER is required, and a pinned one is not read. A hand-written field typed ConfigField<T> names its variable only as string, which is allowed and opens the environment its graph accepts (see The variables a graph reads).
| Constructor | Value | Options |
|---|---|---|
Config.string(variable, options?) | a non-empty string | { default?: string } |
Config.integer(variable, options?) | a whole number, bounds inclusive | { default?: number; min?: number; max?: number } — unset bounds mean the safe-integer range, so an unbounded field still refuses 1e400 |
Config.boolean(variable, options?) | a flag: true/false, 1/0, yes/no or on/off, case-insensitive | { default?: boolean } |
Config.port(variable, options?) | a whole number in 0..65535, 0 (an ephemeral bind) included | { default?: number } |
Config.url(variable, options?) | a URL, kept as the string it was written as | { default?: string } |
Config.list(variable, options?) | a comma-separated list, entries trimmed and the empty ones dropped | { default?: readonly string[]; min?: number } — min defaults to 1, or to 0 when a default is given |
Config.pinned(value, field) | field unless value is given, then a field answering value and reading nothing — checked by the field's own rule | — |
Semantics shared by every field, in one place:
| Raw value | Result |
|---|---|
unset (undefined) | the default, or ConfigFieldInvalid("is required") when there is none |
"" or whitespace only | is set but empty — an error, never the default |
abc (integer/port) | is not a whole number: "abc" |
3.5 (integer/port) | is not a whole number: "3.5" — named, not truncated |
0x1F90 (integer/port) | is not a whole number: "0x1F90" — decimal only, not read as 8080 |
| out of range (integer/port) | must be between <min> and <max>, got <n> — both bounds inclusive |
0 (port) | valid — a port's floor is 0 so an ephemeral bind stays expressible |
issuer.test/jwks (url) | is not a URL — a URL needs its scheme |
pg://u:p@h:bad/d (url) | is not a URL — malformed values are omitted to protect credentials |
a, b, (list) | ["a", "b"] — trimmed, and a trailing separator is not an entry |
, (list) | must list at least 1, got 0 — the min floor, named |
Values are trimmed before being read, Config.string included: X=" abc " binds "abc". That is what makes a whitespace-only variable "set but empty" rather than a value, and it is the one thing to know before putting a secret whose surrounding whitespace is significant in the environment — pin that one through the composition root, where Config.pinned hands the value over untouched.
Integers are decimal digits and an optional sign, then Number() and Number.isInteger; an unbounded Config.integer spans the safe-integer range. "" being an error rather than an absent variable is what stops PORT= binding the ephemeral port through Number("") === 0.
The grammar is narrower than Number's on purpose. Number also accepts 0x1F90, 1e3, 0b101, 0o17 and 7.0, and a manifest holding any of them is far likelier to be a typo than an intention — PORT=0x1F90 binding 8080 is a field guessing, where naming it is a field an operator can argue with.
A leading sign is the one spelling kept: +5 and -5 are decimal, unambiguous and mean what they look like, so a bound is what refuses -5 for a port rather than the grammar.
A flag is true/false, 1/0, yes/no or on/off, in either case. Anything else is an error rather than a falsy reading: a deployment that wrote HTTP_COMPRESSION=enabled meant to turn it on, and silently reading that as false is a configuration bug nothing reports.
Config.url keeps the string it was given rather than answering a URL: the value is what a consumer hands to new URL, and the field is what stops that construction from throwing — a malformed HTTP_JWT_JWKS_URI is a ConfigInvalid naming the variable at graph build, instead of a Defect from wherever the URL is finally needed. It checks parseability, not the scheme: file:///keys.json is a URL, so an endpoint that must be reachable over HTTP is the consumer's own check.
The message redacts the URL's userinfo, so postgres://user:hunter2@db:notaport/orders is reported as postgres://***@db:notaport/orders. DATABASE_URL, REDIS_URL, SMTP_URL and AMQP_URL all carry credentials in that position, and this message reaches stderr through runMain's startFailed line at exit 78 — the log an operator pastes into a ticket. Everything that helps diagnose the value stays: the scheme, the host, the bad port and the path.
Config.list splits on commas and nothing else: an entry containing one is not expressible, which is the trade for a variable an operator can read. min defaults to 1, so a variable set to separators alone is a deployment mistake rather than an empty list — unless a default is given, and then it is 0: { default: [] } is the composition root saying what it wants when nobody sets the variable, and under the flat floor that was always refused, with a message naming a variable the deployment never touched. An explicit min still applies to the default, and to a pin: Config.pinned([], Config.list("HTTP_SESSION_KEYS")) is still refused, because that field has no default and a session codec with no key to seal with is broken rather than empty. It says nothing about what the entries mean — HTTP_SESSION_KEYS is a list of base64url keys, and that each is 32 bytes is @btravstack/http-server/session's own check, reported against the same variable at boot.
Config.pinned is what a starter's options do to its own fields, so precedence is explicit > environment > default, per field: HttpModule's port: 0 pins PORT and still reads HOST.
A pin is validated by the field's own check, where the field has one. Config.pinned(-1, bodyLimit) is a ConfigInvalid at graph build, with the message the deployment route would have produced for HTTP_BODY_LIMIT=-1 — and Config.pinned(NaN, …) likewise, which is the case that used to disable a limit in silence (size > NaN is false). Defaults are checked on the same rule.
integer, port and url carry a check; string does not, and that is deliberate: "set but empty" is a rule about the raw variable — a deployment mistake — where a pinned "" is a decision, and HttpModule's cors: false pins exactly that as its off switch. A field written by hand without a check likewise accepts whatever it is pinned, so nothing about the shape above stops compiling.
Config.object(fields)
Config.object<F extends Record<string, ConfigField<unknown>>>(fields: F):
ConfigSchema<
/* { the required variables: string } & { the optional ones?: string } */,
{ readonly [K in keyof F]: /* the field's T */ }
>A record of fields, as a Standard Schema v1 over the environment (~standard: { version: 1, vendor: "btravstack", validate }). validate:
- is synchronous and never throws — a field whose
parsedefects (a bug in the field, not the deployment) is folded into an issue against its variable,message: String(cause); - reads every field before answering, so one validation names every offending variable at once, in declaration order;
- reports each failure as
{ message, path: [variable] }.
ConfigSchema<Input, Output> is the structural slice of Standard Schema this package speaks, restated locally so it depends on nothing:
type ConfigSchema<Input, Output> = {
readonly "~standard": {
readonly version: 1;
readonly vendor: string;
readonly validate: (value: unknown) =>
| { readonly value: Output; readonly issues?: undefined }
| { readonly issues: readonly ConfigIssue[] }
| Promise</* either of the above */>;
readonly types?: { readonly input: Input; readonly output: Output } | undefined;
};
};
type ConfigIssue = {
readonly message: string;
readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined;
};Any Standard Schema — a zod, valibot or arktype object over the raw variables, synchronous or asynchronous — is accepted wherever a ConfigSchema is. The fields exist so a starter, and an application with ordinary needs, bring no schema library at all.
Config.parse(port, schema)(env)
Config.parse<Output>(port: string, schema: ConfigSchema<Environment, Output>):
(env: Environment) => AsyncResult<Output, ConfigInvalid>;The validation step on its own: it awaits schema["~standard"].validate(env) inside fromSafePromise and answers Ok(value) or one ConfigInvalid naming port and every offending variable. port is a string here because there may be no port — this is the form for a piece that is already its own provider and has no second port to hang a Config.provider on.
jwtAuthenticator is the worked case, in packages/http-server/src/jwt.ts: an authenticator is not a Provider, so it takes Env in its inject record and calls Config.parse("HttpJwt", schema)(env) inside its make arm — and the ConfigInvalid that answers becomes the graph's own startup error, so a deployment missing HTTP_JWT_ISSUER fails the boot with the variable named rather than refusing every caller.
Config.provider is this call plus a port: its make arm is Config.parse(port.portId, schema)(env), and nothing else.
Config.provider
Two overloads over one body, curried like di's own Provider(port)(…): the first call names the port, the second says how it is bound.
Config.provider<P extends AnyPort>(port: P):
<I extends Environment>(schema: ConfigSchema<I, ServiceOf<P>> & SetPortGate<P>) =>
Provider<InstanceType<P>, ConfigInvalid, EnvNeed<I>> & { readonly port: P };
Config.provider<const Name extends string>(name: Name):
<I extends Environment, Output>(schema: ConfigSchema<I, Output>) =>
Provider<PortInstance<Name, Output>, ConfigInvalid, EnvNeed<I>> & { readonly port: PortClassOf<Name, Output> };I is the schema's input — the environment it reads — and EnvNeed<I> the need naming those variables (see The variables a graph reads).
| Form | When |
|---|---|
Config.provider(Port)(schema) | the port is public API another package names (HttpConfig); you declared it, and pass the class |
Config.provider("Name")(schema) | the slice is one application's own; the port is minted, typed by the schema's output, and handed back on provider.port |
A set port is refused at the schema, with di's SetPortGate marker: a set port takes members, which Provider.member contributes.
The provider has dep [Env] and a make arm that awaits schema["~standard"].validate(env) inside fromSafePromise — an async or throwing third-party schema is handled, a throw becoming the defect it is — and answers Ok(value) or Err(new ConfigInvalid({ port: port.portId, issues })). The port is built with the rest of the graph, so a bad environment is a modeled startup Err in the module's own error channel, still typed.
import { Config, Env } from "@btravstack/config";
import { Module, Port, Provider } from "@btravstack/di";
class Database extends Port("Database")<{ readonly url: string }> {}
const databaseConfig = Config.provider("DatabaseConfig")(
Config.object({
url: Config.string("DATABASE_URL"),
poolSize: Config.integer("DATABASE_POOL_SIZE", {
min: 1,
max: 64,
default: 8,
}),
}),
);
const Persistence = Module("Persistence")({
needs: [Env],
provides: [
databaseConfig,
Provider(Database)({
inject: { config: databaseConfig.port },
sync: ({ config }) => ({ url: config.url }),
}),
],
exports: [Database],
});Persistence carries ConfigInvalid in its error channel and Env in its needs; the kernel discharges the second.
The variables a graph reads
A schema's input type is the environment it reads: Config.object's is built from its fields, and a zod object's is its own input. Config.provider turns it into its need — EnvReading<Required, Optional>, which is Env naming those variables — so a module's needs union every variable its configuration reads, and start, runMain and @btravstack/testing's boot type their env by it (see start).
type EnvReading<Required extends string, Optional extends string> = Env & {
readonly "~env": { readonly required: Required; readonly optional: Optional };
};
type EnvNeed<I> = /* EnvReading over I's keys, or plain Env for an open record */;
type EnvironmentFor<N> = /* the record a graph with needs N accepts */;| Type / value | What it is |
|---|---|
EnvReading<R, O> | Env, as a need naming the variables its reader requires (R) and can do without (O) |
EnvNeed<I> | what reading a schema with input I costs a provider: EnvReading over I's keys, or plain Env when I is an open record |
EnvironmentFor<N> | the record a graph with needs N accepts: every required variable, every optional one, nothing else — a variable one reader requires and another defaults is required |
Config.env(schema) | Env typed as reading what schema reads — the same port at run time — for a provider that parses the environment itself with Config.parse rather than Config.provider |
EnvPortFor<I> | the type Config.env answers |
A reader that names nothing opens the record. A provider injecting Env itself, a hand-written field whose variable is a plain string, or a schema whose input is a union of shapes with different keys says nothing exact about what it reads, so EnvironmentFor is then Environment and any record is accepted — the behaviour every graph had before. A starter's own module types name their variables; a module's needs are only as precise as its least precise reader.
Errors
ConfigInvalid — TaggedError("ConfigInvalid")<{ port: string; issues: readonly ConfigIssue[] }>. The message is one line per issue, naming the port and every variable:
HttpConfig could not be configured:
PORT: is required
HOST: is set but emptyAn object path segment prints its key; an issue with no path prints (environment). Under runMain a ConfigInvalid — or a RuntimeStartFailed whose cause is one, the kernel's own PROBE_PORT — is exit code 78; see runMain and exit codes.
ConfigFieldInvalid — TaggedError("ConfigFieldInvalid")<{ reason: string }>, message = reason. The error a single field's parse answers, so Config.object matches it by tag rather than over a bare string. It never leaves Config.object; a caller sees ConfigInvalid.
A URL carrying a credential: cleartext and cleartextRefused
Config.url says a value parses, not that it is safe to talk to. A starter handed a URL that carries a credential — an issuer a client secret is sent to, a key set that decides which tokens are accepted — asks the one rule:
const cleartext: (url: string, allowed: boolean) => boolean | "refused";false for https:; true for http: on a loopback host (localhost, 127.0.0.1, [::1]) or when allowed — the caller's pinned opt-in — and the caller then permits plaintext; "refused" for any other http: URL. cleartextRefused({ port, variable, option, on, what }) builds the ConfigInvalid a boot then answers, naming the variable and the option that would override it, so every caller explains the rule in the same words. oidc(), jwtAuthenticator and oauthClient() apply it. The opt-in is an option rather than a variable everywhere, because its silent change is a security regression.
Summary of exports
| Export | Kind |
|---|---|
Env | port |
Environment | type |
Config | value — string, integer, boolean, port, url, list, pinned, object, parse, provider, env |
ConfigField<T, V, Need> | type |
EnvReading<R, O> | type |
EnvNeed<I> | type |
EnvironmentFor<N> | type |
EnvPortFor<I> | type |
ConfigSchema<I, O> | type |
ConfigIssue | type |
ConfigInvalid | error |
ConfigFieldInvalid | error |
cleartext | value |
cleartextRefused | value |