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.
The pattern
Section titled “The pattern”Define one base, then layer each environment over it. Override only what
differs; everything else inherits.
// 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:
// 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.
Why it resolves at synthesis
Section titled “Why it resolves at synthesis”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 aconstis static. Only a computed access likeconfig[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).
Stable per-environment names
Section titled “Stable per-environment names”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.
When you outgrow it
Section titled “When you outgrow it”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.
Try it
Section titled “Try it”The full example is
lexicons/k8s/examples/layered-config
— it builds in CI.
cd lexicons/k8s/examples/layered-confignpm installnpm run build # → k8s.yaml (three environments)npm run lint # the gate — clean