@evinvest/settings
v0.4.0
Published
Typed, validated env settings with aggregate error reporting and a server/client split — zero runtime deps, mirroring the settings Cargo feature. Secrets stay in sops at the shell/CI boundary; this package only reads the already-injected environment.
Readme
@evinvest/settings
Typed, validated env settings with aggregate error reporting and a
server/client split — zero runtime dependencies, one server-safe,
browser-safe ESM entry. The TypeScript mirror of the settings Cargo feature
of ev_lib.
This package reads environment variables only — no config files, no hot reload, and no decryption: secrets management stays at the shell/CI boundary (sops + age). See the GUIDE and the Rust GUIDE for the full sops workflow.
Install
npm i @evinvest/settingsUsage
One settings.ts per app, evaluated at module scope so a bad environment fails
the boot — and, imported from next.config.*, the build:
import { createSettings, list, port, presets, secret, str, url, withDefault } from '@evinvest/settings';
export const settings = createSettings({
server: {
SESSION_REDIS_URL: url(),
PORT: withDefault(port(), '8080'),
BANKING_ISSUANCE_TOKEN: secret(str()),
MFE_ALLOWED_ORIGINS: withDefault(list(), ''),
...presets.posthog(), // POSTHOG_KEY / POSTHOG_HOST, canonical names
},
clientPrefix: 'NEXT_PUBLIC_',
client: {
...presets.posthogClient(), // NEXT_PUBLIC_POSTHOG_KEY / _HOST
},
// explicit destructure: bundlers inline NEXT_PUBLIC_* / import.meta.env.*
// only for static member expressions
runtimeEnv: {
SESSION_REDIS_URL: process.env.SESSION_REDIS_URL,
PORT: process.env.PORT,
BANKING_ISSUANCE_TOKEN: process.env.BANKING_ISSUANCE_TOKEN,
MFE_ALLOWED_ORIGINS: process.env.MFE_ALLOWED_ORIGINS,
POSTHOG_KEY: process.env.POSTHOG_KEY,
POSTHOG_HOST: process.env.POSTHOG_HOST,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
},
});
settings.PORT; // number — typed, validated
settings.POSTHOG_KEY; // string | undefined (optional)
// on the client: settings.SESSION_REDIS_URL throws (server-only)A missing/invalid environment throws one SettingsError listing every
problem:
invalid settings (2 problems)
- SESSION_REDIS_URL: missing
- PORT: invalid value "banana": expected a finite numberValidators: str, num, int, port, bool, url, list, oneOf —
refined by optional(v), withDefault(v, 'literal'), secret(v),
requiredIn(v, 'production').
Optional here, mandatory in production
The setting that hurts is not the missing required one — that already stops the
boot. It is the optional whose absence is a silent no-op: no SMTP_HOST means
mail is logged instead of sent, no SENTRY_DSN means the alerts never arrive.
requiredIn names the profiles where that convenience ends:
SMTP_HOST: requiredIn(optional(str()), 'production'),
PUBLIC_ORIGIN: requiredIn(withDefault(url(), 'http://localhost:3000'), 'production', 'staging'),invalid settings (1 problem)
- SMTP_HOST: missing (required when APP_ENV=production)The profile is APP_ENV from runtimeEnv (empty counts as unset, default
development). In a Next.js app pass profile: process.env.NODE_ENV instead of
introducing a second, drifting name — and pass it explicitly whenever a client
setting uses requiredIn, since the browser bundle carries no APP_ENV. With an
explicit profile the message drops the variable name (…required when the
active profile is production), so it never points at a variable that had no say.
Failing the boot with a useful exit code
orExit(() => …) prints the aggregate message and exits 78 (EX_CONFIG) on a
SettingsError, rethrowing anything else. Exit 1 is indistinguishable from "a
dependency blinked", which a restart fixes; 78 says a restart cannot.
// instrumentation.ts — before the server accepts traffic
export function register() {
orExit(() => assertConfig());
}Rust ↔ TS parity
The Rust crate is the source of truth; this package preserves its semantics. The full mapping table lives in the Rust README; the load-bearing shared rules:
- var names are written-out SCREAMING_SNAKE keys; required by default;
optional(v)↔Option<T>;withDefault(v, lit)↔= "lit"(the literal parses by the same rules, only when unset). - the empty string is unset;
boolacceptstrue/false/1/0case-insensitively;listsplits on,, trims items, drops empty items; scalars are not trimmed; number grammar matches RustFromStr(int: plain decimal;num: decimal/point/exponent, no hex). Documented divergences: JS numbers are doubles, sonum()requires finite values (Rustf64also acceptsinf/NaN) andint()stops at the safe range±(2^53 - 1)— usestr()for 64-bit ids. - errors aggregate into one
SettingsError(message shape shared with the RustDisplayimpl);secret(v)redacts values in error output. requiredIn(v, …)↔#[required_in(…)], matched against the same canonicalAPP_ENVwith the samedevelopmentfallback, checked before a default applies, and rejected on a setting that is already required everywhere (a compile error on the Rust side, a thrown declaration error here). The profile semantics are pinned by mirrored tests:test/profile.node.test.ts↔rust/src/settings/tests.rs(required_in_*).- the contract is pinned by mirrored vectors:
test/contract.node.test.ts↔rust/src/settings/tests.rs(mod contract). Change both sides or neither.
TS-only (browser-bundler concerns, no Rust equivalent): the server/client
split with clientPrefix, the explicit runtimeEnv destructure, the profile
override, and the NEXT_PUBLIC_* client presets. Rust-only: watch_drift()
(the settings_drift feature), a backend concern (a browser bundle has no
Secret to drift from).
Limitations
- Env-only, flat. No config files, no
__nesting — by design. runtimeEnvmust destructure explicitly for client vars: bundlers (Next.jsNEXT_PUBLIC_*, Viteimport.meta.env) inline only static member expressions at build time.secret(v)redacts what the library emits (errors/issues). JS has noDebugboundary —console.log(settings.TOKEN)still prints the value (unlike Rust, where the generatedDebugprints***).- Worker runtimes: the default server detection is "no
windowinglobalThis" — passisServerexplicitly in web workers.
Develop
npm run typecheck && npm test && npm run build