Crust logoCrust

Env

Typed, validated environment variables as a Context, documented in help and man pages.

@crustjs/env declares environment variables with the same vocabulary as flags and exposes the validated values as a Context.

Install

npm install @crustjs/env

@crustjs/core is a required peer. TypeScript is an optional peer.

Quick example

import { Crust, CrustError } from "@crustjs/core";
import { defineEnv } from "@crustjs/env";

const env = defineEnv("env", {
	DATABASE_URL: { type: "url", required: true, description: "Postgres connection" },
	PORT: { type: "number", default: 3000 },
	LOG_LEVEL: { type: "string", choices: ["debug", "info", "warn"], default: "info" },
	TAGS: { type: "string", multiple: true, delimiter: "," },
});

const app = new Crust("app").provide(env()).command("serve", (cmd) =>
	cmd.action(async ({ ctx }) => {
		const { , PORT } = await ctx.env;
		console.log(`listening on ${PORT} for ${DATABASE_URL.host}`);
	}),
);

defineEnv(name, vars, options?) returns an ordinary Context factory. Provide it at the root for the whole tree, or on a subcommand for only that subtree. Setup is lazy: the variables are read and validated on the first ctx.env read, so commands that never read it never fail. Read it first thing in the action, as with every Context.

Variable definitions

Keys are literal variable names. Each value is a flag definition without the argv-only keys short, aliases, noNegate, negatable, and hidden, and without env; those keys are type errors, and defineEnv() throws a DEFINITION CrustError if an untyped caller passes one. type, choices, required, default, multiple, delimiter, parse, description, and Standard Schema schema behave as they do for a flag read from its environment fallback, and the value types are inferred the same way.

  • defineEnv() checks and copies each definition as defineFlag() does, so a default outside choices or an empty delimiter throws a DEFINITION CrustError when the Context is defined. Help and validation both use that copy; changing the definition object afterwards has no effect.
  • Values run through Core's flag pipeline: choices, then parse or built-in coercion, then defaults and requiredness, then Standard Schemas (async schemas are awaited).
  • type: "boolean" is true for true or 1 and false for any other text. A boolean schema receives that boolean, not the raw text.
  • multiple: true treats the text as one occurrence. With delimiter, the text is split and empty segments are dropped; zero segments counts as unset.
  • Only own properties of the source are read, so a name like constructor never reads Object.prototype.

Options

Prop

Type

const testEnv = defineEnv(
	"env",
	{ PORT: { type: "number", default: 3000 } },
	{ source: { PORT: "8080" } },
);

Option settings are read once, when defineEnv() is called. The values in source are read at first access, so tests can fill the record after defining the Context.

An empty string is a value by default: PORT="" fails type: "number", and NAME="" is "" rather than its default. Opt in to emptyStringAsUndefined when blank variables mean unset.

skipValidation: true is an escape hatch for build steps that run without the real environment. A literal true types every declared variable as string | undefined; a non-literal boolean, or options that may be undefined, types the value as either the raw or the validated shape. Undeclared variables are never returned.

Errors

A failed read rejects with one CrustError whose code is "ENV". details.issues lists every missing or invalid variable in declaration order as { name, expected, received }:

const outcome = await app.run(["serve"]);
if (outcome.status === "failed" && outcome.error instanceof CrustError && outcome.error.is("ENV")) {
	for (const { name, expected, received } of outcome.error.details.issues) {
		console.error(`${name}: ${received} (expected ${expected})`);
	}
}

received is "missing" or "invalid", and expected describes the declaration ("number", "one of: debug, info, warn", "list of string", "value accepted by schema"). Values never appear in the message, details, or toJSON(), and the error carries no cause, because parser and schema messages can quote a secret.

Help and man pages

.provide(env()) adds an "Environment" section (man ENVIRONMENT) to the providing command, listing each variable with its description, default, and choices:

Environment:
  DATABASE_URL  Postgres connection
  PORT          [default: 3000]
  LOG_LEVEL     [default: "info"] [choices: debug, info, warn]
  TAGS

The section appears only on the command where the Context is provided, not on its descendants, and never shows source values.

Out of scope

Loading .env files, a prefix option (names are literal), and a warn-only mode are not supported.

On this page