Skip to content

Build-Time Parameters

A real application often needs to vary a build: which environment, which tier, which region. The naive way is process.env.LOOM_TIER read at module scope. It works, and it is exactly the kind of value TypeScript as Data refuses to fold — an ambient read depends on whatever process happens to be running the build, not on the source. Build-time parameters are the supported alternative: the same environment-dependence, moved out of module scope and into the build invocation, where it is explicit, validated, and recorded.

Declare each parameter in chant.config.ts’s buildParams:

chant.config.ts
import type { ChantConfig } from "@intentius/chant";
export default {
buildParams: {
tier: {
type: "string",
enum: ["light", "production", "production-ha"],
default: "light",
},
env: {
type: "string",
default: "dev",
// Opt-in, EXPLICIT env-var fallback — the only place an env var may
// feed a build-time parameter. Reading process.env directly from
// project source is never supported.
env: "LOOM_ENV",
},
},
} satisfies ChantConfig;

Each parameter declares a type ("string" | "number" | "boolean"), an optional default, an optional enum of allowed values, and an optional env mapping.

Terminal window
# Highest precedence — repeatable
chant build --param tier=production --param env=staging
# Second precedence — a JSON file of { "name": value }
chant build --params-file ./params.prod.json
# Third precedence — only when the parameter declares an `env` mapping
LOOM_ENV=staging chant build
# Lowest precedence — chant.config.ts's declared default
chant build

Precedence, most to least specific: --param > --params-file > the parameter’s own declared env mapping > default. A parameter with no default and no supplied value is a build error naming the parameter — never a silently-undefined value, and never a thrown error from inside project source:

build parameter "tier" has no value — pass --param tier=<value>, use --params-file, or add a default in chant.config.ts's buildParams
build parameter "tier" must be one of "light", "production", "production-ha", got "bogus"

This replaces a hand-written validator like:

function tierFromEnv(): Tier {
const raw = process.env.LOOM_TIER ?? "light";
if (!VALID_TIERS.includes(raw)) throw new Error(`LOOM_TIER must be one of ${VALID_TIERS.join(", ")}, got "${raw}"`);
return raw as Tier;
}

A declared enum reports the same violation as a build error naming the parameter, instead of a thrown Error from inside a function chant has no way to attribute.

Source imports the resolved values from @intentius/chant/params — never reads process.env:

import { params } from "@intentius/chant/params";
export const tier = params.tier as Tier;
export const env = params.env ?? "dev";

params is a plain object, so ordinary property access, optional chaining, and ?? all work exactly as they do on any other value.

A parameter declared with required: false and no default may go unset without a build error. An unset optional parameter is absent from params, so params.<name> reads as undefined, never null. A default written as ?? x applies, a truthiness check is false, and a property that receives the bare value (spec: { baseImageArn: params.baseImageArn }) is dropped from the output in both JSON and YAML rather than shipped as baseImageArn: null.

chant.config.ts
baseImageArn: { type: "string", required: false, env: "KMV_BASE_IMAGE_ARN" },
// params.ts
export const baseImageArn =
(params.baseImageArn as string | undefined) ?? `arn:aws:lambda:${region}:aws:microvm-image:al2023-1`;

An explicitly supplied empty string (--param name= or an exported-but-empty env var) is a value, not “unset”. It reaches params.<name> as "" and does not fall through ??. A null in a --params-file is rejected as a build error.

Because the value is known at build invocation — before any project file is imported or folded — a params.<name> reference resolves to a literal, not a symbolic node, and not a function call. env: process.env.LOOM_ENV ?? "dev" cannot fold: the value comes from ambient state at module-evaluation time. env: params.env folds to "dev" (or whatever the build was invoked with) the same way a const does, so chant build --fold reduces it with zero module execution.

Determinism is preserved, not weakened: same parameters in, same output out. The parameters are supplied explicitly and recorded (BuildResult.buildParams), rather than read invisibly from whatever environment happened to be running the build.

The ownership marker follows the parameter

Section titled “The ownership marker follows the parameter”

chant.config.ts is evaluated before build parameters exist, so a config field cannot read params.env. For the one config field that naturally wants to vary with the build, the ownership marker’s env, the config references the parameter instead:

export default {
ownership: { stack: "fountain", env: { param: "env" } },
buildParams: {
env: { type: "string", default: "dev", env: "FOUNTAIN_ENV" },
},
} satisfies ChantConfig;

--param env=prod then sets the chant.intentius.io/env label and the params.env value in source from one resolution. Before this, a config that wanted both had to read process.env itself for the marker and declare a parameter for the label, and nothing kept the two in step. A build with a literal ownership.env next to an env parameter that resolved to a different value warns, naming both values.

A reference that cannot be satisfied, a parameter the config does not declare or one that resolved to no value, is a build error. The marker is what --owned filtering, drift and prune key on, so a marker stamped without an env is the quiet failure this exists to rule out.

Interpolating the same parameter into physical names is what lets several instances of one stack coexist in one account — chant build --param env=staging and --param env=prod then produce disjoint names and disjoint markers from the same source. The parameter can also declare env: "CHANT_ENV" so chant build --env <name> supplies it, validated against the project’s declared environments. Both patterns are worked through in Resource Naming.

chant build --fold rejects a bare process reference with a message naming this mechanism, not the generic “unresolved identifier”:

ambient "process" read is not foldable — declare a build-time parameter instead
(chant.config.ts's buildParams + `chant build --param name=value`/`--params-file`)
and reference it via `import { params } from "@intentius/chant/params"`,
rather than reading process.env directly