Skip to content

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).

Serializer (packages/core/src/serializer.ts) requires name, rulePrefix and serialize. The other two members are optional:

MemberRequiredTypeNotes
nameYesstringThe lexicon name. Also the key its output is filed under in BuildResult.outputs
rulePrefixYesstringEvery rule id this lexicon ships must start with it. chant dev check-lexicon fails otherwise (chant #1349)
serialize(entities, outputs?, context?)Yesstring | SerializerResultThe whole job
extraRulePrefixesNoreadonly string[]Further id families this lexicon owns. k8s declares ["ARGO", "FLUX"], cedar ["DWD"]
serializeCrossRef(output)NounknownHow this lexicon renders a reference to another lexicon’s LexiconOutput

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).

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);
},
};

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, and lexicons/k3d/src/serializer.ts for a shorter one over the same walker.

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.

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:

FieldTypeWhat it does
verbatimFilesstring[]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)
warningsstring[]Non-fatal diagnostics, collected into the build’s warnings array. A dialect dropping keys the target ignores reports them here

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.

Two core types support child projects:

  • stackOutput(ref, options?) from @intentius/chant/stack-output — wraps an AttrRef, an intrinsic containing one, or a literal string into a Declarable with kind: "output". The serializer emits it into the template’s Outputs section. options carries description, exportName (a cross-stack export) and condition (chant #2068).
  • ChildProjectInstance from @intentius/chant/child-project — a Declarable representing a reference to a child project directory. The build pipeline detects these, recursively builds the child, and attaches the BuildResult.

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 syntax
export 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
}
}

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.
}
}
  1. Discovery stops at child project boundaries. findInfraFiles() treats a nested directory carrying its own chant.config.ts as a separate scope and does not descend into it (packages/core/src/discovery/files.ts)
  2. Build detects ChildProjectInstance entities and recursively builds each child project
  3. Cycle detection tracks the build stack and errors on circular references
  4. Serialization receives entities with populated buildResult — the serializer extracts child templates and emits parent references

See lexicons/aws/src/nested-stack.ts and lexicons/aws/src/serializer.ts for a complete working example.

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:

#CaseWhat it asserts
1Serializer nameserializer.name is the lexicon name
2Rule prefixserializer.rulePrefix is the declared prefix
3Empty mapserialize(new Map()) returns ""
4Single resourceOne entity produces valid output in your format
5Derived nameA camelCase export name becomes your format’s name form (kebab-case for k8s and cedar)
6Explicit name preservedAn author-set name is never overwritten by the derived one
7Multiple entitiesJoined correctly (a --- separator for YAML, one file per entity for k3s and k3d)
8Defaults appliedWhatever your format injects when the declaration says nothing
9Explicit overrides winA resource-level value beats the default
10Property entities skippedA kind: "property" entity is inlined where it is referenced, never emitted as its own document
11Key orderingOutput keys in canonical order, independent of the order props were written in
12Format-specificCedar 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.ts and lexicons/k8s/src/serializer-ownership.test.ts.
  • Round-tripping, where the emitted text parses back to the declared values. lexicons/k3d/src/serializer.test.ts does this with js-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.

With serialization in place, the next step is to write lint rules for your provider.