Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/scoped-config-slices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@btravstack/config": minor
---

configuration parsed from the environment, as a port and adapter

`Config(port, "PREFIX")({ key: validator })` implements an ordinary
`@btravstack/di` port — declared by the starter with `Port(id)<Service>` —
as a module that parses `PREFIX`-scoped environment variables, validated
with any Standard Schema library. The port stays adaptable: a test can hand
it a literal `Provider(port)({ value: ... })` instead, with no config module
involved. `Config.source` provides the environment as a port rather than an
ambient `process.env` read, so validation and injection can never disagree.
`Config.collect` walks a module tree for every reachable env adapter, and
`Config.parse` validates them all against one source, aggregating every
wrong variable into one `ConfigInvalid` instead of stopping at the first.
`@btravstack/config/zod` ships `wholeNumber` and `port` builders that guard
against the `Number("") === 0` trap.
21 changes: 21 additions & 0 deletions packages/config/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Benoit TRAVERS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
188 changes: 188 additions & 0 deletions packages/config/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# @btravstack/config

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

illustrate it using it in the examples/


Configuration parsed from the environment, wired through
[`@btravstack/di`](https://github.com/btravstack/di) as an ordinary port and
adapter.

A starter declares a **port** the normal way — `Port(id)<Service>` — and
`Config(port, prefix)(shape)` is one **adapter** for it: a di module that
implements the port by parsing `prefix`-scoped environment variables. The
port stays a port: a test can hand it a literal, and a future file or
secret-manager source can be a different adapter for the same port. Neither
is possible when the port and its environment parser are welded into one
value — this package deliberately keeps them apart.

## Declaring a port and its env adapter

```ts
import { Config, type ValueOf } from "@btravstack/config";
import { Port } from "@btravstack/di";
import { z } from "zod";

const shape = {
url: z.string().min(1).default("amqp://127.0.0.1:5672"),
prefetch: z.string().min(1).pipe(z.coerce.number<string>().int()).default(10),
};
export class AmqpConfig extends Port("AmqpConfig")<ValueOf<typeof shape>> {}
export const AmqpConfigFromEnv = Config(AmqpConfig, "AMQP")(shape);
Comment on lines +18 to +27

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why cannot we do that ?

Suggested change
import { Config, type ValueOf } from "@btravstack/config";
import { Port } from "@btravstack/di";
import { z } from "zod";
const shape = {
url: z.string().min(1).default("amqp://127.0.0.1:5672"),
prefetch: z.string().min(1).pipe(z.coerce.number<string>().int()).default(10),
};
export class AmqpConfig extends Port("AmqpConfig")<ValueOf<typeof shape>> {}
export const AmqpConfigFromEnv = Config(AmqpConfig, "AMQP")(shape);
import { Config, } from "@btravstack/config";
import { Port } from "@btravstack/di";
import { z } from "zod";
export const amqpConfig = Config("AmqpConfig")({
url: z.string().min(1).default("amqp://127.0.0.1:5672"),
prefetch: z.string().min(1).pipe(z.coerce.number<string>().int()).default(10),
};);

amqpConfig is a btravstack/di module that can be imported in a module and it exports a config provider of kind ConfigType<typeof amqpConfig>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can — but not for the reason I gave you earlier, and I owe you a correction first.

What I got wrong. I argued the welded slice could not be adapted: that a test could not write Provider(AmqpConfig)({ value: … }) because it would collide with the slice's own provider, and that avoiding the collision meant losing the port. That was too strong. The value was a token; its module statics are only consulted if you put it in imports:. A test could always have done provides: [Provider(AmqpConfig)({ value: … })] and simply not imported it. Adaptability was never actually blocked. You made a call partly on that argument, so it matters that it was overstated.

What does block your snippet, concretely: injection needs a constructor. Context.get is <S extends R>(port: abstract new () => S) => ServiceOf<S> and Provider(port) takes P extends AnyPort. A module is a plain object — { name, imports, provides, exports } — so it satisfies neither. If amqpConfig is a module, then resolving it needs ctx.get(amqpConfig.port): the accessor shape you rejected earlier, just inverted (module primary, port hanging off it).

The way to avoid the accessor is to make the returned value both a module and a port class — which is exactly the shape we removed, and the reason is mechanical rather than architectural: a factory that applies Service itself returns a port class the declaration emitter cannot name, so every consumer got TS4023. That is what btravstack/di#12 (ConcretePortClass) existed to fix, and closing it is only justified while no factory returns a port class.

So your snippet is available at a known price: reinstate that di export, and re-open the PR I closed. No other cost — the emit fix was one exported type plus a regression guard, both already written and verified.

And ConfigType<typeof amqpConfig> already exists, as ValueOf<typeof shape> — the parsed type derived from the schema record, which is what stops the service type being written twice. In the current split it reads Port("AmqpConfig")<ValueOf<typeof shape>>.

What the split still buys, stated honestly now that adaptability is off the table: no library change (di stays as published), the port declared where a reader looks for it, and a compile-time check that the adapter's parsed output satisfies the port's service — which a single welded value has no way to state, because there is nothing to check it against.

What it costs: two names, and the Port(...) line you would rather not write.

Your call — I will take either, and the one-value version is a small, already-proven change.

```

Each value in `shape` is a [Standard Schema](https://standardschema.dev)
validator — a `zod` schema above, but any Standard-Schema-compliant library
works. `ValueOf<typeof shape>` types the port from the shape, so the service
type and the schema are never written twice. A starter ships the port and
its env adapter together, alongside the properties it defines: the shape and
the defaults live with the code that gives them meaning, not in a central
file every consumer has to know about.

The port is declared by the starter, not by `Config`, because that is what
makes it adaptable: `Config(port, prefix)(shape)` is just one provider for
`port`, on equal footing with any other. An application imports the env
adapter; a test imports nothing from this package at all:

```ts
// the application
import { Module } from "@btravstack/di";
import { OkAsync } from "unthrown";

const App = Module("App")({
imports: [
ApplicationModule,
PersistenceModule,
AmqpConfigFromEnv,
Config.source({ AMQP_URL: "amqp://broker" }),
],
exports: [AmqpConfig],
});

const value = await Module.scoped(App, (ctx) => OkAsync(ctx.get(AmqpConfig)));
// Ok({ url: "amqp://broker", prefetch: 10 })
```

```ts
// a test, or any other adapter — ordinary di, no config involvement
import { Module, Provider } from "@btravstack/di";

const Test = Module("Test")({
provides: [
Provider(AmqpConfig)({ value: { url: "amqp://fake", prefetch: 1 } }),
],
exports: [AmqpConfig],
});
```

`imports: [AmqpConfigFromEnv]` provides the port; `ctx.get(AmqpConfig)` reads
it back — from either adapter, unchanged. An adapter imported by two
different modules in the same graph is still parsed once — di dedupes the
module before its provider ever runs.

`Config(port, prefix)(shape)` also enforces, at compile time, that `shape`
actually implements `port`: `shape`'s parsed output must be assignable to
`port`'s own service type. A shape missing a key the port declares, or with
the wrong type for one, fails to compile — the same way a wrong `Provider`
qualification would.

## Naming: camelCase in, `PREFIX_SCREAMING_SNAKE` out

Each key of the shape maps to an environment variable named by the adapter's
prefix and the key shouted: `url` under `Config(port, "AMQP")` reads
`AMQP_URL`; `prefetch` reads `AMQP_PREFETCH`. The rule is a straight case
conversion — the key's own camelCase word boundaries become underscores — so
a validator's keys can stay idiomatic TypeScript while the environment stays
idiomatic shell. A multi-word key splits at every boundary: `prefetchCount`
under `Config(port, "AMQP")` reads `AMQP_PREFETCH_COUNT`. An acronym run is
left alone, not split letter-by-letter: `urlBase` reads `URL_BASE`, but
`URLBase` — the acronym already shouted in the key itself — reads `URLBASE`.

## `Config.source`: the environment as a port

Env adapters don't read `process.env` directly. `Config.source(record)` is
the module that provides `ConfigSource` — the one place a plain
`Record<string, string | undefined>` enters the graph:

```ts
Config.source(process.env);
```

The environment is a port, not an ambient read, so that validating the
configuration and actually providing it can never disagree about what the
environment was. If an adapter read `process.env` itself while validation
read a snapshot taken earlier, the two could see different values — a
variable set between the two reads would pass validation and then fail to
inject, or the reverse. Threading one `ConfigSource` through both removes the
seam where that mismatch could happen.

## Validating everything before boot: `Config.collect` and `Config.parse`

`Config.collect(module)` walks a module tree and returns every env adapter
reachable from it, each once, regardless of how many times or how deep it is
imported:

```ts
const adapters = Config.collect(App);
```

`Config.parse(adapters, source)` validates all of them against one source
and aggregates every issue — from every adapter — into a single
`ConfigInvalid`:

```ts
const outcome = Config.parse(adapters, process.env);
```

`outcome` is `Ok()` when every adapter agrees with the environment, or an
`Err` carrying one `ConfigInvalid` — `{ issues: [{ variable, message }, ...] }`
— when any of them don't. This is the one-report guarantee: `Config.parse`
does not stop at the first wrong variable. An operator who mistyped three
environment variables learns about all three from one failed boot, not one
deploy per fix. `describeIssues` formats the issues as one line each,
`" VARIABLE: message"`, joined for a log or an exit message:

```ts
import { describeIssues } from "@btravstack/config";

if (outcome.isErr()) {
console.error(describeIssues(outcome.error.issues));
}
```

`Config.collect` walks the **root** module tree only. An adapter reachable
exclusively through a `Module.forkScope` request module — one built fresh per
request, layered over an already-built parent `Context` — is not part of that
tree and is therefore not covered by this pre-boot validation pass. That gap
is a deliberate phase-2 design decision (`@btravstack/start-core` owns when
and how forked request modules get validated), not something this package
solves; an adapter declared only inside a request module today is still
parsed by its own provider, just without the one-report-before-boot
guarantee.

## `@btravstack/config/zod`: `wholeNumber` and `port`

Two `zod` builders for the coercions configuration validation needs most:

```ts
import { port, wholeNumber } from "@btravstack/config/zod";

const httpShape = {
port: port(3000),
maxConnections: wholeNumber(100, 1, 10_000),
};
class HttpConfig extends Port("HttpConfig")<ValueOf<typeof httpShape>> {}
const HttpConfigFromEnv = Config(HttpConfig, "HTTP")(httpShape);
```

`wholeNumber(fallback, min, max)` reads a string, requires it non-empty,
coerces it to an integer, and bounds it — falling back to `fallback` only when
the variable is genuinely absent. The non-empty check in front of the
coercion is load-bearing: `Number("")` is `0`, so a variable set to nothing
(`PORT=`) would otherwise silently become the ephemeral port `0` instead of
raising a configuration error. The bounds alone cannot catch that case,
because for `port` — `wholeNumber(fallback, 0, 65_535)` — `0` is a legitimate
value, not just the coercion of an empty string.

## What's next

Kernel integration — turning a `ConfigInvalid` into process exit code
`EX_CONFIG` and wiring zero-config entry points — arrives in
`@btravstack/start-core` in phase 2. This package only validates and provides;
it never reads `process.env` on its own and it never exits the process.
91 changes: 91 additions & 0 deletions packages/config/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
{
"name": "@btravstack/config",
"version": "0.0.0",
"description": "Environment variables validated into typed config, wired through @btravstack/di as a port and its adapter",
"keywords": [
"configuration",
"dependency-injection",
"environment",
"standard-schema",
"typescript",
"unthrown",
"validation"
],
"homepage": "https://github.com/btravstack/start#readme",
"bugs": {
"url": "https://github.com/btravstack/start/issues"
},
"license": "MIT",
"author": "Benoit TRAVERS <benoit.travers.fr@gmail.com>",
"repository": {
"type": "git",
"url": "https://github.com/btravstack/start.git",
"directory": "packages/config"
},
"files": [
"dist"
],
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.cts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./zod": {
"import": {
"types": "./dist/zod.d.mts",
"default": "./dist/zod.mjs"
},
"require": {
"types": "./dist/zod.d.cts",
"default": "./dist/zod.cjs"
}
},
"./package.json": "./package.json"
},
"scripts": {
"build": "tsdown src/index.ts src/zod.ts --format cjs,esm --dts --clean",
"test": "vitest run --coverage",
"test:types": "tsc --noEmit -p tsconfig.test-d.json",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
},
"dependencies": {
"@standard-schema/spec": "catalog:",
"@unthrown/standard-schema": "catalog:"
},
"devDependencies": {
"@btravstack/di": "catalog:",
"@btravstack/tsconfig": "catalog:",
"@types/node": "catalog:",
"@unthrown/vitest": "catalog:",
"@vitest/coverage-v8": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"unthrown": "catalog:",
"vitest": "catalog:",
"zod": "catalog:"
},
"peerDependencies": {
"@btravstack/di": "^0.1.0",
"unthrown": "^5.0.0",
"zod": "^4"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
},
"engines": {
"node": ">=20"
}
}
57 changes: 57 additions & 0 deletions packages/config/src/collect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Module, Port } from "@btravstack/di";
import { describe, expect, it } from "vitest";
import { z } from "zod";

import { collect } from "./collect.js";
import { Config, type ValueOf } from "./slice.js";

const aShape = { x: z.string().default("a") };
class APort extends Port("A")<ValueOf<typeof aShape>> {}
const A = Config(APort, "A")(aShape);

const bShape = { y: z.string().default("b") };
class BPort extends Port("B")<ValueOf<typeof bShape>> {}
const B = Config(BPort, "B")(bShape);

describe("collect", () => {
it("finds an adapter nested anywhere in the import tree", () => {
// GIVEN an adapter imported by a module imported by the root
const Inner = Module("Inner")({ imports: [A], exports: [APort] });
const Root = Module("Root")({ imports: [Inner, B], exports: [APort, BPort] });

// WHEN the tree is walked
// THEN both adapters are found, wherever they sit
expect(collect(Root)).toEqual(expect.arrayContaining([A, B]));
});

it("returns an adapter imported twice only once", () => {
// GIVEN two modules importing the same adapter
const One = Module("One")({ imports: [A], exports: [APort] });
const Two = Module("Two")({ imports: [A], exports: [APort] });
const Root = Module("Root")({ imports: [One, Two], exports: [APort] });

// WHEN the tree is walked
// THEN the adapter appears once — it is parsed once, so it is reported once
expect(collect(Root)).toEqual([A]);
});

it("returns adapters in the order they were declared", () => {
// GIVEN a root importing two adapters directly, in a given order
const Root = Module("Root")({ imports: [A, B], exports: [APort, BPort] });

// WHEN the tree is walked
// THEN they come back in that same order — `describeIssues` promises one
// line per issue "in the order the adapters declared them"
expect(collect(Root)).toEqual([A, B]);
});

it("ignores ordinary modules", () => {
// GIVEN a tree with no adapters at all
const Plain = Module("Plain")({});
const Root = Module("Root")({ imports: [Plain] });

// WHEN the tree is walked
// THEN nothing is reported
expect(collect(Root)).toEqual([]);
});
});
Loading
Loading