Skip to content

@btravstack/config

Reference. A complete, structured description of @btravstack/config: the Env port, the field constructors, Config.object, Config.parse, Config.provider and 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

ts
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:

ts
type ConfigField<T> = {
  readonly variable: string;
  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>;
};
ConstructorValueOptions
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
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 valueResult
unset (undefined)the default, or ConfigFieldInvalid("is required") when there is none
"" or whitespace onlyis 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
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: "issuer.test/jwks" — a URL needs its scheme
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 Number() plus 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.

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.

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. 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: http({ 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 http({ 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)

ts
Config.object<F extends Record<string, ConfigField<unknown>>>(fields: F):
  ConfigSchema<Environment, { 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 parse defects (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:

ts
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)

ts
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.

ts
Config.provider<P extends AnyPort>(port: P):
  (schema: ConfigSchema<Environment, ServiceOf<P>>) =>
    Provider<InstanceType<P>, ConfigInvalid, Env> & { readonly port: P };

Config.provider<const Name extends string>(name: Name):
  <Output>(schema: ConfigSchema<Environment, Output>) =>
    Provider<PortInstance<Name, Output>, ConfigInvalid, Env> & { readonly port: PortClassOf<Name, Output> };
FormWhen
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

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.

ts
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.

Errors

ConfigInvalidTaggedError("ConfigInvalid")<{ port: string; issues: readonly ConfigIssue[] }>. The message is one line per issue, naming the port and every variable:

text
HttpConfig could not be configured:
  PORT: is required
  HOST: is set but empty

An 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.

ConfigFieldInvalidTaggedError("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.

Summary of exports

ExportKind
Envport
Environmenttype
Configvalue — string, integer, boolean, port, url, pinned, object, parse, provider
ConfigField<T>type
ConfigSchema<I, O>type
ConfigIssuetype
ConfigInvaliderror
ConfigFieldInvaliderror

Released under the MIT License.