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

import type { Serializer, Declarable } from "@intentius/chant";
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) {
resources[name] = {
type: entity.entityType,
};
}
return JSON.stringify({ resources }, null, 2);
},
};

The basic example above only emits entity.entityType. A real serializer must handle property values — including AttrRef (attribute references like bucket.Arn), nested Declarable references (property types), and Intrinsic values (Sub, Ref, etc.). Property names use spec-native casing and are passed through verbatim.

Use walkValue and SerializerVisitor from @intentius/chant/serializer-walker to handle all of these generically:

import type { Serializer, Declarable } from "@intentius/chant";
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
const visitor: SerializerVisitor = {
// Convert AttrRef → your format (e.g. CFN's Fn::GetAtt)
attrRef(logicalName, attribute) {
return { "Fn::GetAtt": [logicalName, attribute] };
},
// Convert resource Declarable reference → your format (e.g. CFN's Ref)
resourceRef(logicalName) {
return { Ref: logicalName };
},
// Convert property Declarable → walked object of its properties
propertyDeclarable(entity, walk) {
const props: Record<string, unknown> = {};
for (const [key, val] of Object.entries(entity)) {
if (key === "entityType" || key === "lexicon") continue;
props[key] = walk(val);
}
return props;
},
};
const mySerializer: Serializer = {
name: "my-lexicon",
rulePrefix: "MY",
serialize(entities: Map<string, Declarable>): string {
// Build a 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) {
const properties: Record<string, unknown> = {};
for (const [key, val] of Object.entries(entity)) {
if (key === "entityType" || key === "lexicon") continue;
properties[key] = walkValue(val, entityNames, visitor);
}
resources[name] = { type: entity.entityType, properties };
}
return JSON.stringify({ resources }, null, 2);
},
};

See lexicons/aws/src/serializer.ts for the full AWS CloudFormation serializer.

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

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) from @intentius/chant/stack-output — wraps an AttrRef into a Declarable with kind: "output". The serializer emits it into the template’s Outputs section.
  • 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 being exported
// output.description is optional
}
}
  1. Discovery stops at child project boundaries — findInfraFiles() skips child project subdirectories
  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.

Your serializer test file (serializer.test.ts) should cover at minimum:

  1. Identityserializer.name and serializer.rulePrefix are correct
  2. Emptyserialize(new Map()) returns empty string
  3. Single resource — produces valid output format
  4. Name generation — camelCase export → kebab-case metadata name
  5. Explicit name — user-set name is preserved
  6. Multi-doc — multiple entities joined correctly
  7. Default labels — merged into all resources
  8. Default annotations — merged into all resources
  9. Explicit overrides — resource-level labels take precedence
  10. Property entitieskind: "property" skipped in output
  11. DefaultLabels/Annotations entities — skipped in output
  12. Key ordering — canonical order (e.g., apiVersion, kind, metadata, spec)

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.