Skip to content

TypeScript as Data

chant reads the resource objects your TypeScript exports. It supports a specific subset of the language, the part whose value is fully determined by literals, constants, and cross-resource references. chant build reduces your files directly to those objects without executing them; a file that steps outside the subset is imported and run instead. The subset exists so that either path has no side effects and always yields the same output. This page documents exactly which patterns are supported and which are not.

The core pattern: export a const binding initialized with a typed resource constructor.

import { StorageType } from "@intentius/chant-lexicon-<name>";
export const store = new StorageType({
name: "my-data",
versioned: true,
});

The evaluator extracts the export name (store), resolves the resource type from the lexicon, and evaluates the constructor argument.

Strings, numbers, booleans, null, arrays, and nested object literals are evaluated directly.

import { ServiceType } from "@intentius/chant-lexicon-<name>";
export const service = new ServiceType({
name: "handler",
timeout: 30,
memorySize: 128,
environment: {
variables: {
DEBUG: "false",
},
},
});

const bindings with literal initializers can be referenced by name. The evaluator traces the reference to its initializer and evaluates it.

import { StorageType } from "@intentius/chant-lexicon-<name>";
const tags = { project: "myapp", env: "prod" };
export const store = new StorageType({
name: "data",
tags: tags,
});

Only const bindings are supported. let and var are mutable and cannot be statically traced.

Object spread is supported when the source is a const binding with a known literal value.

import { ServiceType } from "@intentius/chant-lexicon-<name>";
const defaults = { timeout: 30, memorySize: 128 };
export const service = new ServiceType({
...defaults,
name: "handler",
});

Standard import statements are followed through the module graph. The evaluator resolves imported names to their definitions in other files.

import { StorageType } from "@intentius/chant-lexicon-<name>";
import { sharedTags } from "./shared";
export const store = new StorageType({
name: "data",
tags: sharedTags,
});

Import a resource from another file and access its attributes. The evaluator resolves the attribute through the lexicon’s attribute registry.

import { ServiceType } from "@intentius/chant-lexicon-<name>";
import { dataTable } from "./table";
export const service = new ServiceType({
environment: {
variables: {
TABLE_ARN: dataTable.arn,
},
},
});

Lexicons can register tagged template literals for provider-specific intrinsics (e.g., string substitution with deploy-time values):

import { StorageType, Intrinsic, Params } from "@intentius/chant-lexicon-<name>";
export const store = new StorageType({
name: Intrinsic`${Params.StackName}-data`,
});

See your lexicon’s documentation for available intrinsics.

Most intrinsics are not tagged templates. AWS writes Ref(...), GetAtt(...), Join(...); Azure writes Concat(...), Reference(...). A lexicon can opt one of these into folding, one intrinsic at a time:

import { StorageType, Ref, Sub, Params } from "@intentius/chant-lexicon-<name>";
export const store = new StorageType({
name: Sub`${Params.StackName}-${Ref(environment)}-data`,
owner: Ref(environment),
});

This is the one place a call folds that isn’t chant’s own authoring helpers, and it is a closed list, not a rule about what calls look like. Three things must hold: the lexicon registered the intrinsic, the lexicon marked that intrinsic’s call form foldable (foldsAsCall — off unless written, never inferred), and the name is bound by this file’s own import. Your own function that happens to be called Ref is not the lexicon’s, so it does not fold. Neither does a method call (aws.Ref(...)), an array .map(...), or an intrinsic whose lexicon has not opted it in.

Nothing about the value changes: the function that runs is the one your import names, called with statically-folded arguments, producing the same intrinsic object the run path would have produced. Your lexicon’s intrinsics page has a Folds? column saying which of its intrinsics are opted in.

Sub-resource properties can use typed constructors for autocompletion and validation:

import { EncryptionConfig, AccessConfig } from "@intentius/chant-lexicon-<name>";
export const config = new EncryptionConfig({
algorithm: "AES256",
});
export const access = new AccessConfig({
publicAccess: false,
});

The ?? operator is supported for providing default values, particularly useful in composite props.

timeout: props.timeout ?? 30,

A function call is not a value chant can evaluate statically, with one closed exception: chant’s own authoring helpers. phase(), gate(), activity(), stackOutput() and output() are pure functions of their arguments, and they are the API chant documents for writing components and outputs — source using them cannot avoid a call and still use the feature. The converge rule builders are registered on the same grounds. when() and the predicate and action builders it composes each return a plain record built from their arguments, and a ConvergeOp rule table cannot be written without them.

import { phase, stackOutput, type Component } from "@intentius/chant/components";
export const web: Component = {
name: "web",
dependsOn: ["shared-foundation"],
deploy: [
phase("Apply", [
{
kind: "cfn-deploy",
stack: "web",
inputs: { pVpcId: stackOutput("shared-foundation", "oVpcId") },
},
]),
],
};

The list is a hand-written allowlist in chant’s source (packages/core/src/fold/foldable-helpers.ts), not a rule about what calls look like. Two things must both hold before one folds: the callee is a bare identifier naming a registered helper, and that name is bound by an import from chant itself. A helper of your own that happens to share a name is not chant’s, so it does not fold — the file falls back to running, exactly as any other call does. Registering a name never causes chant to substitute its own reimplementation: the function that runs is the one your import names, so a folded call and a run call are the same call.

Helpers that read the environment are deliberately excluded. env() is a function call like the others, but its result depends on CHANT_ENV rather than on its arguments, so folding it would freeze one run’s environment into the output. Use build-time parameters where you need an external value in foldable source.

A call to a function from one of your own files folds when the function’s body is itself inside the supported subset (#1373). This is the shape a parameter file takes when defaulting and validation live in one helper rather than inline at every use:

lib/target.ts
export function optionalAccountId(raw: string | undefined): string | undefined {
const trimmed = raw ?? "";
return trimmed === "" ? undefined : trimmed;
}
// params.ts
import { params } from "@intentius/chant/params";
import { optionalAccountId } from "./lib/target";
export const namingParams = {
prefix: "kmv",
accountId: optionalAccountId(params.accountId),
};

Nothing is imported or run. The arguments fold in the calling file, the parameters are bound, and the body folds in the defining file’s scope, so a module-level const or an import of lib/target.ts resolves exactly as it would have there. The callee can be a function declaration or a const bound to an arrow or function expression, exported or declared in the same file, and can call other functions of the same kind. Parameters may have defaults and may destructure an object with plain keys.

The body follows the same statement rules as an interpretable composite factory: a single expression, or const declarations followed by one return. No if, loop, throw, let/var, async, rest parameter, or early return. Inside it, three things that fold at a file’s top level do not: a new Type(...), a tagged-template intrinsic, and a registered helper or intrinsic call. Each of those produces an object that is built against the importing file’s own imports, which is not the scope the body was written in, so they are refused rather than built against the wrong file.

When a body does not fold, the calling file falls back to run, and the reason names the callee, the file and position inside it, and what stopped it:

[fold:run] params.ts — "namingParams" is not foldable: 7:14 - call to "optionalAccountId" (src/lib/target.ts)
is not foldable: src/lib/target.ts:3:17 - ambient "process" read is not foldable — declare a build-time parameter instead ...

The function is a callable, not a value. { resolver: optionalAccountId } does not fold, because nothing can serialize a function. Only project files produce a callable: a function imported from a package, chant’s own modules included, is still a call fold cannot evaluate and is invoked the way composite factories are, or falls back.

The following patterns are caught by the evaluability lint rules (EVL). Your editor shows them as lint errors, and chant lint reports them in CI. chant build does not fail on them — a pattern below makes fold reject that one file, which then falls back to being imported and run, producing whatever JavaScript produces, correct or not. Run lint, or rely on your editor, to keep source inside the supported subset.

On the fold path (see Folded vs Run below), the same subset is what a file must stay inside to fold at all: a pattern below makes fold reject that one file and fall back to running it, logged with the reason. That fallback is not a build error either — it just means the file loses the no-execution property for this run.

// EVL001: Expression not statically evaluable
export const store = new StorageType({
name: getName(), // function call — not a literal
});

A function from a package, a function used as a value, and a method call (naming.name(...), list.map(...)) are never evaluated statically — the value would depend on running code. Three exceptions fold. Two are closed allowlists of names, checked against what your file actually imported: chant’s own registered authoring helpers, and a lexicon intrinsic whose call form its lexicon opted in. The third is open but local: a call to a function from one of your own project files whose body is itself in this subset. EVL001 still flags the call above because the lint rule sees one file at a time and cannot know what getName does; the fold decides by reading the body.

// EVL002: Resource inside control flow
if (env === "prod") {
export const store = new StorageType({...});
}

Resources must be top-level exports. For environment-specific configuration, use separate files or composites with different prop values.

// EVL003: Dynamic property access
const name = config[key];
// EVL004: Spread from non-const source
export const store = new StorageType({
...getDefaults(),
});
  • let/var bindings — only const is statically traceable
  • Class declarationsclass MyStore extends StorageType {...}
  • Template literals without known tags — only lexicon-registered tags
  • Computed property names{ [key]: value }
  • require() — only import statements
  • Top-level await
  • Decorators

For the full EVL rule reference with configuration options, see Evaluability Rules.

Folding is the default build path since #1134: instead of importing and running a file, chant reduces its AST directly to the resource spec, with zero module execution. A file that steps outside the fold subset falls back to being imported and run, on its own, and the evaluability rules (EVL, above) are what keep that fallback deterministic — they reject anything a running file could do besides read literals, constants, and cross-resource references, so running the supported subset has no side effects and no dependency on the environment.

Nothing changes about what you write to get folding — the supported subset above is the same subset that folds. What changes is how the build gets there:

Fold (default)Run
MechanismReduces the AST to a value — no executionImports and executes the file
What keeps it deterministicThe fold subset itself (a build-time construction)The evaluability rules (a lint-time discipline)
A pattern outside the subsetRejected for that file, which falls back to running it insteadStill runs; produces whatever the code produces
OutputResource objects — byte-identical serialized outputSame resource objects
How you get itOn by default since #1134--no-fold, build.fold: false, or a per-file fallback

Folding happens per file, not per project. A file either folds entirely or falls back to running entirely — chant never partially executes a file. chant build --verbose logs which path each file took, and why, in a real run (without the flag, the same decisions collapse to one line: fold: 1 files folded, 1 ran (--verbose for reasons)):

[fold:fold] tags.ts — 1 resource(s), no module execution
[fold:run] outputs.ts — "dataBucketArn" is not foldable: 4:37 - unresolved identifier: dataBucket

Fold covers more than a single leaf resource now:

  • Leaf resourcesexport const x = new Type({ ...literals, const refs, templates }) (#1021/#1026).
  • Composite factory callsexport const stack = MyStack({...}), propagate(MyStack({...}), {...}), member access on the result (web.deployment), and destructuring (export const { a, b } = MyStack({...}), or a local const destructured and re-exported by name) all fold (#1022). Two ways, tried in that order:
    • Interpreted (#1023) — when the composite is defined in one of your own project files as export const MyStack = Composite((props) => …, "MyStack") and its factory body stays inside the interpretable subset, chant evaluates the body instead of calling it. The defining module is never imported, so nothing of yours runs at all — which is also what lets such a file fold under --sandbox instead of being demoted to the child.
    • Invoked — everything else: a composite a lexicon package publishes, a factory whose body leaves the subset, a wrapper function of your own. Chant resolves it through the file’s own imports and calls it for real with statically-folded arguments. The file still folds; the factory itself runs. Under --sandbox a project-owned callee is refused here and the file falls back to the sandboxed child.
  • Registered intrinsic tagged templates — a lexicon can register a tagged template (e.g. a string-substitution helper) as foldable; when it’s registered, an interpolation inside it folds like any other value in the subset. A tagged template whose tag isn’t registered falls outside the subset. See your lexicon’s intrinsics page for which of its intrinsics are tagged templates.
  • Registered intrinsic calls — the same, for an intrinsic authored as a plain call (Ref(...), Concat(...)), where its lexicon has opted that intrinsic in (#1044). Arguments fold by the ordinary subset rules; the real function is resolved through the file’s own imports and invoked, so the intrinsic object is the one the run path would have built. Opt-in is per intrinsic and off by default — see Registered intrinsic calls.
  • Nested constructions — a new Type(...) used as a value rather than as the file’s own top-level export folds too (#1169), whether it is written inline (image: new Image({ name: "node:22" }), a Container inside a pod spec, a Rule in a job’s rules array) or named once and referenced (const nodeImage = new Image({...}) then image: nodeImage in three jobs). The instance is built by the class your import names, so what the enclosing constructor receives is the object the run path would have handed it — the same prototype, the same toJSON, the same serialized shape. A named one is built exactly once per file, in source order, and every reference in that file reads the same object, so a resource referenced by name is the same entity chant registers.
  • Cross-file references — an imported const, or an imported resource referenced through an attribute, resolves through the module graph and folds in the defining file’s own scope, including re-export chains (#1020). Each cross-file export folds exactly once per build and every referrer shares that one instance, so a symbolic reference always points at the same entity the defining file produced. An import cycle is rejected with a located error rather than followed.
  • Project-local function calls — a call to a function declared in one of your own files (or in the file itself), when its body stays inside the subset, evaluates statically against the defining file’s scope (#1373). The defining module is never imported. A body that does not fold names the callee and the reason in the [fold:run] line. See Project-local function calls.
  • Lexicon package exports — the same resolution follows a bare import into a lexicon package, so a lexicon’s plain data exports fold as values: Azure’s and GCP’s pseudo-parameter namespaces (Azure.ResourceGroupLocation, GCP.ProjectId), AWS’s S3Actions, GitLab’s CI (#1063). Only lexicons this build actually loaded are followed, and only the package itself, never a subpath or an unrelated package — the boundary is the lexicon list your project already declares or chant already detected, not node_modules at large. The lexicon module is imported the same way the run path imports it, so what fold captures is the same object, not a copy.

What still forces a fallback to run, per file: a function call used as a value that is neither a registered authoring helper, an opted-in intrinsic, nor a project-local function with a foldable body — a package’s function, an array .map(...), a method call, an intrinsic whose lexicon has not opted it in, a helper of your own whose body reads the environment or does I/O. Also: a composite call nested as a value inside something else rather than being the file’s own top-level export, a new ns.Type(...) through a namespace import (the class has to be reachable through a named import to be constructed), a re-export, export default, let/var, and an exported class declaration.

process.env.X and other environment-dependent references are rejected by the same mechanism, but not because of a gap left to close: an environment read is a value fold can only get by executing code and consulting whatever process happens to be running the build, which is exactly what folding is built to avoid. It’s an unresolved identifier like any other, and the file falls back to run by design — reported with a pointed message rather than the generic “unresolved identifier”, naming the alternative below.

If a project needs to vary a build (an environment name, a tier that changes which resources are even produced), the supported way is a build-time parameter (#1064), not process.env: declare it in chant.config.ts’s buildParams (type, optional default/enum/env mapping), supply it via chant build --param name=value / --params-file, and reference it in source as params.<name> (import { params } from "@intentius/chant/params"). Because the value is known at build invocation — before any file is imported or folded — params.<name> folds to a literal, exactly like a const, instead of falling back to run. Values are recorded on the build result (BuildResult.buildParams) so a build’s inputs are auditable, not read invisibly from whatever environment happened to be running it. See Build-Time Parameters.

Fold and run are also kept from ever disagreeing about a shared entity’s identity, in both directions. If file B imports file A and B falls back to run, A is forced back to run too, even if A would have folded on its own — otherwise B’s real import of A and A’s own folded copy would be two different objects. And if A is forced back to run for that reason, any file that already folded while capturing one of A’s objects (a cross-file resource reference, a resource passed to an intrinsic) is forced back too, since the instance it captured is no longer the instance the build collects.

None of this is something you opt into piecemeal or write differently for — it’s the existing supported subset, enforced by construction instead of by lint. Settable project-wide via chant.config.ts’s build.fold (default true); an explicit --fold/--no-fold flag always wins when passed.

Migrating from pre-#1134 builds: the one observable change of fold-by-default is that a folded file’s module-scope side effects (a console.log, a global registry mutation, an ambient read at import time) no longer run at build time — eliminating exactly that execution is what folding is. Files outside the subset still run, unchanged. If a build genuinely depends on module side effects, --no-fold (or build.fold: false) restores the pre-#1134 behavior wholesale, and each [fold:fold] log line under chant build --verbose tells you which files stopped executing.

A composite you define in your own source is interpreted rather than called when all of the following hold (#1023). Everything else keeps invoking, exactly as before — a factory outside this subset is not an error, it just runs.

  1. The calling file imports it from one of your own files (a relative or absolute specifier). A composite a lexicon package publishes is deliberately not interpreted: an installed lexicon ships compiled JavaScript, so whether its factory bodies were interpretable would depend on whether that package happened to ship its TypeScript — and a lexicon package is already trusted code the CLI loaded before discovery began, so there is nothing to gain.
  2. That file declares it as export const Name = Composite(<fn>, "Name"), with Composite imported from chant. A plain helper function that returns a composite (export function SecureApi(props) { return LambdaApi({...}); }) is not a registered composite and stays on the invoking path.
  3. <fn> takes at most one parameter, bound as a plain identifier or a simple object pattern — no default, rest, or nested binding.
  4. Its body is a single expression, or const declarations followed by one return. No if, throw, loop, let/var, nested function declaration, or bare expression statement.
  5. Every expression in it is in the ordinary supported subset, extended with the two things a factory body exists for: new Type(...) in any value position (a member, a nested property object, an array element), and a call through a bare identifier (a nested composite, a registered helper, an opted-in intrinsic). A method call (naming.name(...), .map(...)) is a property-access callee and stays out.

A body that references one of the defining module’s module-level resources also declines: that resource is a singleton the run path shares across every call, and interpretation would not.

What you get is what running the factory produced. The resources are built by the lexicon’s own constructors, resolved through the defining module’s imports; a sibling reference inside the body (role.Arn) is a live attribute reference on the instance just built, not a symbolic placeholder; and the instance itself is assembled by chant’s own Composite(), so member validation, propagate() and expansion behave identically by construction rather than by resemblance.

chant build and chant build --no-fold produce byte-identical serialized output for every file that folds — same resource objects, same serialization, same lint. This is proven by a differential test across every shipped example (#1025); run it yourself with just fold-differential.

As of this measurement, 95 of 107 example projects in the corpus fold completely — every file in them reduces to a value with zero module execution. The remaining 12 have at least one file that falls back to running.

This is measured over the same corpus just fold-differential builds both ways: every tutorial under examples/*/src, plus each lexicon’s own build fixtures under lexicons/*/examples/*/src. It’s a count of chant’s own example suite, not a sample of real-world source — read it as “how much of chant’s examples fold today,” not as an estimate for any other codebase.

That corpus is also chant’s own maintained examples, and some of them are expected to be rewritten into foldable shapes over time, or dropped. Both changes move this number without the folder gaining or losing any actual capability: rewriting an example raises the count for a reason unrelated to what --fold can do, and adding or removing an example moves the denominator the same way. Treat a change here as “the corpus changed,” not as evidence either way about fold coverage of your own project.

As measured on the corpus after #1169 (per-file fallback reasons classified by their leading cause): a new Type(...) used as a value no longer blocks anything — that cause went from 64 files, the only blocker in 22 entries, to zero, and took the corpus from 55 entries to 76. Cross-file references into a lexicon package’s own exports had already gone to zero at #1063, from 116 files across 36 entries.

The largest remaining cause is an unresolved identifier: 69 files, though most of those are a chain — the identifier is unresolved because the file that defines it stopped on something else. Behind it is a function call used as a value that is neither a registered authoring helper nor an opted-in intrinsic: 41 files, and now the only blocker in 13 entries — the whole github corpus and forgejo’s, on Checkout(...); three helm examples on include/printf; gitlab/monorepo-pipeline on workspaces.map(...). Those are arbitrary JavaScript and a lexicon that has not been audited yet, in that order. Twelve more files fold in isolation and are held back only by the identity taint of a sibling that doesn’t; nine are process.env, refused on purpose.

Per lexicon, so the causes can be judged separately: k8s went from 5 of 10 to 10 of 10 and fly from 0 of 1 to 1 of 1 — both were capped entirely by nested constructions (new Container(...) inside a pod spec, new MachineConfig(...) inside a machine). GitLab went from 2 of 8 to 6 of 8, AWS from 13 of 19 to 16 of 19, and the root examples/ tutorials from 6 of 22 to 14 of 22. Azure holds at 13 of 13 and GCP at 8 of 8. GitHub stays at 0 of 8: every one of its examples calls Checkout(...), a lexicon composite whose call form nothing has opted in, so nested constructions were never what gated it. That is the third correction to what #1039 predicted — the intrinsics wiring was called “the single biggest lever” and moved one entry, the call form moved eight, lexicon package exports moved twenty-one, and constructions-as-values moved twenty-one more.

These counts are a snapshot, not refreshed by the mechanism above; they’ll drift from the corpus over time the same way the rest of this page would without #1062. Two gaps earlier snapshots named are closed: a resource constructor whose first argument isn’t the props object literal (#1082 — constructor arguments fold positionally, so the AWS Parameter’s new Parameter("String", {...}) folds like any other), and the nested construction above (#1169).

Folded files execute none of their own code, but a run-fallback file does — in the CLI’s own process by default. chant build --sandbox (#1045) runs every run-fallback file for a build together, isolated, in one child process with the filesystem, process-spawn, and environment access locked down, instead of trusting the file to behave. It composes with folding: folded files are unaffected (there’s nothing to isolate), and the run-fallback remainder is what gets sandboxed — see Sandboxed Execution for exactly what that does and does not protect against.

The supported subset is the set of TypeScript patterns whose value is fully determined by literals, known constants, and symbolic cross-resource references. chant build folds source inside that subset and imports and runs whatever falls outside it. The evaluability rules reject patterns that would make the result depend on anything else, so running the supported subset has no side effects either.

This makes synthesis fast, deterministic for conforming source (same source, same output), and auditable (every output value traces to a specific line in the source).

The static boundary also defines a natural division of labor with agentic workflows: agents resolve dynamic values (looking up VPC IDs, reading secrets, querying state), then write or populate chant source files. chant synthesizes the deterministic structure. Agents handle what changes; chant handles what shouldn’t.

For where each kind of value belongs — static data resolved at synthesis, a deploy-time input resolved at apply, or a lookup synthesis refuses — see Where Values Come From.