Skip to content

Layered Configuration

A common need: one composite, deployed across several environments, with per-environment differences. Presets cover the single-level case — shared defaults spread into resources. Layered configuration is the multi-level generalization: a base config overridden per environment, including deep-merged nested objects — using nothing but plain TypeScript object spread. No deepMerge helper, no per-environment files, no codegen.

Define one base, then layer each environment over it. Override only what differs; everything else inherits.

src/config.ts
// Layered configuration — one composite, many environments, no new mechanism.
//
// A base config is overridden per environment with plain TypeScript object
// spread. The nested `labels` object is deep-merged with native nested spread
// (`...base.labels`). Everything is static const-to-const data, so it resolves
// at synthesis — chant's evaluability rules (EVL) permit const spread and
// static member-access spread; only a `deepMerge()` *function call* or a
// computed `config[key]` access would be rejected.
//
// This is the whole of "layered config": presets are the single-level case;
// this is the multi-level generalization, in the language you already have.
import type { WebAppProps } from "@intentius/chant-lexicon-k8s";
// ── Layer 1: base — every environment starts here ──────────────────────────
// Note: no `name` — each environment supplies its own so resources stay
// distinct and collision-free in a single build.
const base = {
image: "ghcr.io/acme/web:1.4.0",
port: 8080,
replicas: 2,
cpuRequest: "100m",
cpuLimit: "250m",
memoryRequest: "128Mi",
memoryLimit: "256Mi",
// A base-only nested object — every environment inherits it unchanged, so it
// is set once here. (Not every nested layer needs a per-env override.)
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
readOnlyRootFilesystem: true,
allowPrivilegeEscalation: false,
capabilities: { drop: ["ALL"] },
},
// A nested object that DOES vary per environment — deep-merged below.
labels: {
"app.kubernetes.io/part-of": "acme-web",
"app.kubernetes.io/managed-by": "chant",
},
};
// ── Layer 2: per-environment overrides, spread over the base ───────────────
// `...base` shallow-merges the top level; `...base.labels` deep-merges the one
// nested object. Override only what differs; everything else inherits.
export const dev: WebAppProps = {
...base,
name: "web-dev",
// dev keeps base replicas/resources; no ingress.
labels: { ...base.labels, "acme.io/env": "dev" },
};
export const staging: WebAppProps = {
...base,
name: "web-staging",
replicas: 3,
cpuLimit: "500m",
memoryLimit: "512Mi",
ingressHost: "staging.acme.example",
minAvailable: 1,
labels: { ...base.labels, "acme.io/env": "staging" },
};
export const prod: WebAppProps = {
...base,
name: "web-prod",
replicas: 6,
cpuRequest: "250m",
cpuLimit: "1",
memoryRequest: "256Mi",
memoryLimit: "1Gi",
ingressHost: "acme.example",
minAvailable: 2,
// Deep-merge: keep the base labels, add prod-only ones.
labels: { ...base.labels, "acme.io/env": "prod", "acme.io/tier": "critical" },
};

Then instantiate the same composite once per environment. Each call is the blessed composite factory call with a static (const) prop bag — the layering already happened in config.ts:

src/web.ts
// One composite, instantiated once per environment from layered config.
//
// Each call is the blessed composite factory call with a static (const) prop
// bag — the layering already happened in config.ts. Per-environment names
// (`web-dev`/`web-staging`/`web-prod`) keep the three stacks' resources
// distinct in a single build.
import { WebApp } from "@intentius/chant-lexicon-k8s";
import { dev, staging, prod } from "./config";
export const devApp = WebApp(dev);
export const stagingApp = WebApp(staging);
export const prodApp = WebApp(prod);

All three build into one manifest. Per-environment names (web-dev / web-staging / web-prod) keep their resources distinct and collision-free.

chant’s evaluability rules (EVL) require resource inputs to resolve to static data. Object spread from a const qualifies, at any depth:

  • { ...base, replicas: 6 } — const spread. Allowed (the same rule presets use).
  • { ...base.labels, "acme.io/env": "prod" }static member-access spread. Allowed: reading a known property of a const is static. Only a computed access like config[key] is rejected (EVL003).
  • deepMerge(base, prod) — a function-call initializer. Rejected by EVL004. This is the one thing that does not work, and it is why the pattern uses native nested spread instead of a merge helper.

So the shallow level is ...base and each nested object you want to merge gets its own ...base.<key>. A nested object you don’t spread is replaced wholesale — the same rule presets follow — which is often what you want (securityContext in the example is set once on the base and inherited unchanged).

Give each environment its own name so resources don’t collide in a single build. In the example the base deliberately omits name, and each environment supplies its own (web-dev, web-staging, web-prod). Thread an env or name through the composite the same way for any multi-environment stack.

This pattern stays in plain TypeScript on purpose — it is deterministic and auditable, and every environment is an explicit, readable object. If you later need to know the effective config for one environment without merging the layers in your head, that is what chant describe projects.

The full example is lexicons/k8s/examples/layered-config — it builds in CI.

Terminal window
cd lexicons/k8s/examples/layered-config
npm install
npm run build # → k8s.yaml (three environments)
npm run lint # the gate — clean