Create a Serializer
The serializer converts chant’s evaluated resource graph into your target platform’s format (e.g. CloudFormation JSON, Kubernetes YAML, Terraform HCL).
The interface
Section titled “The interface”Serializer (packages/core/src/serializer.ts) requires name, rulePrefix
and serialize. The other two members are optional:
| Member | Required | Type | Notes |
|---|---|---|---|
name | Yes | string | The lexicon name. Also the key its output is filed under in BuildResult.outputs |
rulePrefix | Yes | string | Every rule id this lexicon ships must start with it. chant dev check-lexicon fails otherwise (chant #1349) |
serialize(entities, outputs?, context?) | Yes | string | SerializerResult | The whole job |
extraRulePrefixes | No | readonly string[] | Further id families this lexicon owns. k8s declares ["ARGO", "FLUX"], cedar ["DWD"] |
serializeCrossRef(output) | No | unknown | How this lexicon renders a reference to another lexicon’s LexiconOutput |
Authored properties live on entity.props
Section titled “Authored properties live on entity.props”A generated resource class defines lexicon, entityType, kind and props
with enumerable: false (packages/core/src/runtime.ts), and defines the
attribute accessors enumerably. Object.entries(entity) therefore yields the
AttrRef accessors and never the properties the user wrote. Read entity.props,
guarded by isResourceDeclarable, because not every Declarable carries one
(outputs and parameters genuinely do not, chant #1049).
Basic Serializer
Section titled “Basic Serializer”import type { Serializer, Declarable } from "@intentius/chant";import { isResourceDeclarable } from "@intentius/chant/declarable";
const mySerializer: Serializer = { name: "my-lexicon", rulePrefix: "MY",
serialize(entities: Map<string, Declarable>): string { const resources: Record<string, unknown> = {};
for (const [name, entity] of entities) { if (entity.kind === "property") continue; resources[name] = { type: entity.entityType, properties: isResourceDeclarable(entity) ? entity.props : {}, }; }
return JSON.stringify({ resources }, null, 2); },};Using walkValue and SerializerVisitor
Section titled “Using walkValue and SerializerVisitor”The basic example above copies entity.props verbatim, which is wrong as soon
as a property holds an AttrRef (an attribute reference like bucket.Arn), a
nested Declarable (a property type), or an Intrinsic. Property names use
spec-native casing and are passed through unchanged; the values are what need
converting.
Use walkValue and SerializerVisitor from @intentius/chant/serializer-walker
to handle all of them generically. The walker owns the dispatch chain (null,
AttrRef, Intrinsic, Declarable, array, object) and calls your visitor for
the three cases that are format-specific:
import type { Serializer, Declarable } from "@intentius/chant";import { isResourceDeclarable } from "@intentius/chant/declarable";import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
const visitor: SerializerVisitor = { // AttrRef in your format (here, CloudFormation's Fn::GetAtt) attrRef(logicalName, attribute) { return { "Fn::GetAtt": [logicalName, attribute] }; },
// A reference to another top-level resource (here, CloudFormation's Ref) resourceRef(logicalName) { return { Ref: logicalName }; },
// A property-kind Declarable, inlined as its own walked props propertyDeclarable(entity, walk) { if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) { return undefined; } const out: Record<string, unknown> = {}; for (const [key, value] of Object.entries(entity.props as Record<string, unknown>)) { if (value !== undefined) out[key] = walk(value); } return out; },};
const mySerializer: Serializer = { name: "my-lexicon", rulePrefix: "MY",
serialize(entities: Map<string, Declarable>): string { // walkValue resolves a Declarable reference by identity, so it needs the // reverse map: Declarable instance -> logical name. const entityNames = new Map<Declarable, string>(); for (const [name, entity] of entities) { entityNames.set(entity, name); }
const resources: Record<string, unknown> = {}; for (const [name, entity] of entities) { if (entity.kind === "property") continue; const raw = isResourceDeclarable(entity) ? entity.props : {}; const properties = walkValue(raw, entityNames, visitor) as Record<string, unknown>; resources[name] = { type: entity.entityType, properties }; }
return JSON.stringify({ resources }, null, 2); },};See
lexicons/aws/src/serializer.ts(cfnVisitor) for the full AWS CloudFormation serializer, andlexicons/k3d/src/serializer.tsfor a shorter one over the same walker.
Stamping the ownership marker
Section titled “Stamping the ownership marker”serialize receives a third argument, SerializeContext. When the build
carries an ownership marker, the serializer stamps it into the target’s own
metadata channel (tags on AWS and Azure, labels on Kubernetes and GCP). That
stamp is what later lets chant lifecycle delete precisely without a state
file, so a lexicon that ignores it can never report an owned verdict.
import type { SerializeContext } from "@intentius/chant/serializer";import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
serialize(entities, outputs?, context?: SerializeContext): string { const stamp = context?.ownership ? ownershipEntries(LABEL_OWNERSHIP_KEYS, context.ownership) : {}; // merge `stamp` into each resource's labels, never overwriting an // author's own key with the same name}LABEL_OWNERSHIP_KEYS (packages/core/src/ownership.ts) is the shared
label convention, app.kubernetes.io/managed-by plus
chant.intentius.io/{stack,env}. A lexicon whose target uses tag keys with
different syntax supplies its own ChannelKeys. Whatever you stamp, declare it
on the plugin as ownershipChannel so the read paths that resolve a real
verdict are visible to callers; chant dev check-lexicon verifies the
declaration against the members you actually implement (chant #1348).
SerializeContext also carries config (the resolved project config, so a
serializer can read its own namespace) and receipts (effect receipts declared
in this partition, withheld from entities because they must never enter the
apply-bound document, chant #1832).
lexicons/k3d/src/serializer.ts and lexicons/k8s/src/serializer.ts both stamp;
lexicons/k8s/src/serializer-ownership.test.ts is the test to copy.
Multi-file output
Section titled “Multi-file output”If your lexicon supports splitting resources across multiple output files (e.g. nested stacks), return a SerializerResult instead of a plain string:
import type { Serializer, SerializerResult } from "@intentius/chant";
const mySerializer: Serializer = { name: "my-lexicon", rulePrefix: "MY",
serialize(entities): string | SerializerResult { // ... detect if multi-file output is needed ...
if (hasChildProjects) { return { primary: JSON.stringify(parentTemplate, null, 2), files: { "child.template.json": JSON.stringify(childTemplate, null, 2), }, }; }
return JSON.stringify(template, null, 2); },};The build pipeline writes each entry in files alongside the primary output file. File keys are relative filenames (not paths).
SerializerResult carries two further fields:
| Field | Type | What it does |
|---|---|---|
verbatimFiles | string[] | Basenames from files that must reach disk byte-for-byte. The build’s additional-file writer otherwise round-trips a file through JSON.parse and key-sorts it, which corrupts committed ciphertext or pre-formatted YAML (chant #1937) |
warnings | string[] | Non-fatal diagnostics, collected into the build’s warnings array. A dialect dropping keys the target ignores reports them here |
Child Projects
Section titled “Child Projects”If your lexicon needs to split resources into separate deployment units (like AWS nested stacks, Terraform modules, or Azure linked templates), use the core child project pattern. A child project is a subdirectory that builds independently to a valid template.
Core primitives
Section titled “Core primitives”Two core types support child projects:
stackOutput(ref, options?)from@intentius/chant/stack-output— wraps anAttrRef, an intrinsic containing one, or a literal string into aDeclarablewithkind: "output". The serializer emits it into the template’sOutputssection.optionscarriesdescription,exportName(a cross-stack export) andcondition(chant #2068).ChildProjectInstancefrom@intentius/chant/child-project— aDeclarablerepresenting a reference to a child project directory. The build pipeline detects these, recursively builds the child, and attaches theBuildResult.
1. Create your lexicon’s factory function
Section titled “1. Create your lexicon’s factory function”Your lexicon provides a function that creates a ChildProjectInstance. This is the user-facing API for referencing child projects:
import { CHILD_PROJECT_MARKER, type ChildProjectInstance } from "@intentius/chant/child-project";import { DECLARABLE_MARKER } from "@intentius/chant/declarable";import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
// Output ref class for your format's cross-stack reference syntaxexport class ModuleOutputRef { readonly [INTRINSIC_MARKER] = true; constructor(readonly moduleName: string, readonly outputName: string) {} toJSON() { // Your format's reference syntax return `\${module.${this.moduleName}.${this.outputName}}`; }}
export function nestedModule( name: string, projectPath: string, options?: Record<string, unknown>,): ChildProjectInstance { const outputsProxy = new Proxy({} as Record<string, ModuleOutputRef>, { get(_, prop: string) { if (typeof prop === "symbol") return undefined; return new ModuleOutputRef(name, prop); }, });
return { [CHILD_PROJECT_MARKER]: true, [DECLARABLE_MARKER]: true, lexicon: "my-lexicon", entityType: "Module", kind: "resource", projectPath, logicalName: name, outputs: outputsProxy, options: options ?? {}, } as ChildProjectInstance;}The outputs Proxy creates output ref objects on demand — network.outputs.vpcId returns a ModuleOutputRef("network", "vpcId") that serializes via toJSON().
2. Handle ChildProjectInstance in your serializer
Section titled “2. Handle ChildProjectInstance in your serializer”Use isChildProject() to detect child project entities and emit the appropriate resource type:
import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
for (const [name, entity] of entities) { if (isChildProject(entity)) { const child = entity as ChildProjectInstance; // Emit your format's nested module/stack resource // child.buildResult contains the child's serialized output // child.logicalName, child.projectPath, child.options available }}3. Handle StackOutput in your serializer
Section titled “3. Handle StackOutput in your serializer”Use isStackOutput() to detect output declarations and emit them in the appropriate section:
import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output";
for (const [name, entity] of entities) { if (isStackOutput(entity)) { const output = entity as StackOutput; // Emit into your format's outputs section. // output.sourceRef is the AttrRef, intrinsic or literal being exported. // output.description, output.exportName and output.condition are optional. }}How it works at build time
Section titled “How it works at build time”- Discovery stops at child project boundaries.
findInfraFiles()treats a nested directory carrying its ownchant.config.tsas a separate scope and does not descend into it (packages/core/src/discovery/files.ts) - Build detects
ChildProjectInstanceentities and recursively builds each child project - Cycle detection tracks the build stack and errors on circular references
- Serialization receives entities with populated
buildResult— the serializer extracts child templates and emits parent references
See
lexicons/aws/src/nested-stack.tsandlexicons/aws/src/serializer.tsfor a complete working example.
Testing
Section titled “Testing”serializer.test.ts is required by name (chant dev check-lexicon, tier 1).
Twelve cases, in the order the shipped serializer tests use them. Case 12 is
whatever is peculiar to your format:
| # | Case | What it asserts |
|---|---|---|
| 1 | Serializer name | serializer.name is the lexicon name |
| 2 | Rule prefix | serializer.rulePrefix is the declared prefix |
| 3 | Empty map | serialize(new Map()) returns "" |
| 4 | Single resource | One entity produces valid output in your format |
| 5 | Derived name | A camelCase export name becomes your format’s name form (kebab-case for k8s and cedar) |
| 6 | Explicit name preserved | An author-set name is never overwritten by the derived one |
| 7 | Multiple entities | Joined correctly (a --- separator for YAML, one file per entity for k3s and k3d) |
| 8 | Defaults applied | Whatever your format injects when the declaration says nothing |
| 9 | Explicit overrides win | A resource-level value beats the default |
| 10 | Property entities skipped | A kind: "property" entity is inlined where it is referenced, never emitted as its own document |
| 11 | Key ordering | Output keys in canonical order, independent of the order props were written in |
| 12 | Format-specific | Cedar parses its own output back through cedar-wasm; k3s asserts flag names survive as keys |
lexicons/cedar/src/serializer.test.ts is sectioned by these numbers and is the
clearest one to copy.
Two cases the list above does not number, both of which shipped serializers test:
- Ownership stamping, when the build carries a marker, plus the case where an
author’s own key with the same name wins. See
lexicons/k3d/src/serializer.test.tsandlexicons/k8s/src/serializer-ownership.test.ts. - Round-tripping, where the emitted text parses back to the declared values.
lexicons/k3d/src/serializer.test.tsdoes this withjs-yaml.
Use mockResource() and mockProperty() helpers with DECLARABLE_MARKER to
create test entities without constructing real resource classes. See
Testing Your Lexicon for the full pattern.
Next Steps
Section titled “Next Steps”With serialization in place, the next step is to write lint rules for your provider.