Skip to content

Alloy Object Model + Renderer ProposalΒ #364

Description

@steverice

Alloy Object Model + Renderer Proposal

"Spicy String Concatenation" β†’ "Spicy String Generation" 🌢️

1. Goals

Enable Alloy to support three categories of output, each with the appropriate collection pattern:

Category Examples Collection Model
Source code Python, TypeScript, C# Evaluator-driven RenderedTextTree β†’ Prettier
Data/config formats JSON, YAML, TOML, XML Evaluator-driven DataModelTree β†’ format renderer
Schema/definition languages GraphQL, SQL, Protobuf, HCL Component-driven DefinitionState β†’ domain renderer

Preserve Alloy's existing strengths: JSX ergonomics, reactive recomputation, multi-file outputs, and deterministic formatting.

2. Core Design: Evaluator + Collector

The Problem

Today, renderTree serves two roles simultaneously:

  1. Component evaluator: executes ComponentCreator thunks, runs reactive effects, manages contexts, tracks diagnostics and devtools state. This is generic.
  2. Text tree builder: constructs a RenderedTextTree by appending strings and Prettier-backed print hooks into a nested array structure. This is text-specific.

These two roles are interleaved. Every call to appendChild both resolves a child (generic) and places the result into a RenderedTextTree node (text-specific). Intrinsic handling (<indent>, <group>, etc.) maps directly to Prettier builders inside the same function.

The @alloy-js/graphql package already works around this: renderSchema calls renderTree for its component-evaluation side effect (populating SchemaState via context), then discards the returned RenderedTextTree entirely. This confirms that component evaluation is separable from model construction β€” but the current code doesn't make that separation explicit.

The Solution

Factor renderTree into a generic component evaluator that delegates model construction to a pluggable model collector.

renderTree(children, collector?) -> collector.result

The component evaluator owns:

  • Reactive scope setup (root, effect)
  • ComponentCreator thunk execution
  • Child normalization (refs, refkeys, renderables, arrays, primitives)
  • Context management and propagation
  • Reactive re-rendering (replacing subtrees when dependencies change)
  • Diagnostics, devtools, error handling, render stack

The model collector owns:

  • Creating model nodes
  • Handling primitives (strings, numbers) β€” what they mean in this model
  • Assembling child nodes into parent nodes
  • Domain-specific concerns (e.g., text collector tracks content for conditional formatting; data model collector validates structure)

This is analogous to React's host config (see Appendix E): react-dom and react-native provide different host configs to the same reconciler. Here, @alloy-js/source and @alloy-js/data-model provide different model collectors to the same component evaluator.

Intrinsic elements (e.g., <indent>, <group>, <line>) are domain-specific, not core primitives. Each domain package defines its own intrinsics β€” @alloy-js/source defines formatting intrinsics that map to Prettier builders; @alloy-js/data-model may define its own or none at all. Because intrinsics are scoped to their domain package, they simply don't exist in other domains; no cross-domain rejection logic is needed (see Appendix F).

Two Collection Patterns

Model construction happens via one of two patterns:

Evaluator-driven collection β€” the evaluator invokes collector callbacks as it traverses components, producing a model tree. Used when output structure mirrors component tree structure.

  • Source code: The text collector creates RenderedTextTree array nodes, appends strings as text fragments, maps intrinsic elements to Prettier-backed print hooks, and tracks content (isEmpty, childrenWithContent) for conditional formatting. This is a direct extraction of current renderTree behavior.
  • Data/config formats: The data model collector creates DataModelTree nodes. Source-code formatting intrinsics like <indent> are scoped to @alloy-js/source and are not available in data model subtrees.

Component-driven collection β€” components push into shared state via context as they execute. The evaluator just runs components; no collector is needed. Used when output is a graph or flat namespace with cross-references.

  • GraphQL: Schema components (ObjectType, Field, etc.) register into SchemaState. A GraphQL schema is a graph: types reference each other freely (User has a field of type [Post], Post has a field of type User), and the final schema is a flat namespace of named types, not a nested tree. Component nesting in JSX is for authoring convenience, not output structure.
  • SQL: Table, constraint, and index components register into SqlSchemaState. SQL schemas form a graph of foreign key relationships that can be forward references or circular (orders references users, users references default_order). Tables must be emitted in dependency order or use ALTER TABLE for deferred constraints β€” this ordering is a serialization concern, not a component-tree concern.
  • Protobuf: Message and enum components register into ProtoState. A .proto file defines a flat namespace of top-level type definitions where messages reference each other by name. Field numbers must be tracked per-message, and nested message types create a naming scope but not a structural dependency β€” the output is a flat list of definitions, not a tree that mirrors how components are nested.

For guidance on which pattern to use, see Appendix H. For detailed pipeline diagrams per domain, see Appendix C.

Conceptual Pipeline

Component tree (JSX)
  β†’ model (tree or graph, domain-specific)
  β†’ document tree (renderer-specific serialization structure)
  β†’ string output (or file contents)

See the glossary for definitions of each stage.

Renderers are serializers: they do not alter the data. They may make representation decisions (key ordering, layout style, syntax choice) within the target format's constraints. Format-specific rendering hints, provided via context, can guide these decisions.

3. Package Architecture

Package Roles

An Alloy package can take on one or more of the following roles:

  • Runtime: JSX runtime, render scheduling, Output/SourceDirectory, and shared types. (@alloy-js/core)
  • Model producer (component library): JSX components that build a model tree or register into shared state. (@alloy-js/typescript, @alloy-js/python, @alloy-js/graphql)
  • Renderer: converts a model into a document tree and then into output text. (@alloy-js/json, @alloy-js/yaml)
  • Hybrid: combines model production and rendering for a domain with a single output format. (@alloy-js/graphql is a hybrid β€” it provides both schema components and SDL/introspection renderers.)

A package includes only the directories that apply to its roles (see Suggested Directory Layout).

Core (@alloy-js/core)

The runtime package. All domain-specific code moves out; core has no dependency on Prettier. See Appendix B for the current structure this refactors from.

Provides:

  • Generic component evaluator (the factored renderTree)
  • ModelCollector interface (for evaluator-driven domains)
  • JSX runtime, reactivity (effects, refs, computed), context, scheduling
  • File system primitives: Output, SourceDirectory
  • Top-level render() and renderAsync() functions (see Core's Role in Output Assembly)
  • Diagnostics, devtools, refkey system

Domain Packages

@alloy-js/source (evaluator-driven) β€” the source-code model, extracted from today's core:

  • SourceFile component
  • Text collector implementation
  • RenderedTextTree types, print hooks, formatting intrinsics (<indent>, <group>, etc.)
  • Content tracking (isEmpty, createContentSlot, conditional formatting utilities)
  • printTree function (Prettier integration)

@alloy-js/data-model (evaluator-driven):

  • DataFile component
  • Data model collector implementation
  • DataModelTree types
  • Generic data components (<Object>, <Array>, <Scalar>, <Property>) β€” see Appendix D for cross-renderer guarantees

@alloy-js/definitions (component-driven):

  • DefinitionScope component (multi-file coordination)
  • DefinitionFile component
  • DefinitionRegistry<T> (generic nameβ†’definition map with deduplication)
  • DefinitionState interface (contract for domain state implementations)

Format and Language Packages

Language packages (@alloy-js/typescript, @alloy-js/python, @alloy-js/csharp, etc.) remain component libraries. They now depend on @alloy-js/source (instead of core) for formatting primitives.

Format packages (@alloy-js/json, @alloy-js/yaml, @alloy-js/toml, @alloy-js/xml) are renderer packages that convert DataModelTree to their document tree and then to string output. Optional format-specific components (e.g., Yaml.LiteralStyle) live alongside the renderer.

Domain-specific packages (@alloy-js/graphql, @alloy-js/sql, @alloy-js/protobuf) provide domain state implementing DefinitionState, definition components that register into that state, and domain-specific renderers (SDL, DDL, .proto syntax). They use DefinitionFile and DefinitionScope from @alloy-js/definitions.

Suggested Directory Layout

Packages include only the directories that apply:

  • src/components/ β€” JSX surface area (component creators)
  • src/model/ β€” model tree types, builders, normalization
  • src/collector/ β€” model collector implementation
  • src/renderer/ β€” model β†’ document β†’ string pipeline
  • src/document/ β€” document tree types and helpers
  • src/context/, src/builtins/, src/symbols/ β€” as used today

4. File Components and Serialization

Each output category has its own file component that establishes the rendering domain for its subtree:

File Component Package Collection Serialization Props
SourceFile @alloy-js/source Evaluator-driven (text collector) Prettier path, filetype, printOptions
DataFile @alloy-js/data-model Evaluator-driven (data model collector) Format renderer path, format
DefinitionFile @alloy-js/definitions Component-driven Domain renderer path, domain config

DefinitionFile must be wrapped in a DefinitionScope for multi-file coordination.

Lazy Serialization Convention

Each file component stores a serializer callback in context.meta.file:

meta.file = {
  path: "main.py",
  serialize: (options) => /* domain-specific serialization */
}

Serialization happens once after all reactive updates settle, not on every component update. Each file component provides its own serializer without core depending on any domain package.

Example β€” SourceFile:

export function SourceFile(props: SourceFileProps) {
  const subtree: RenderedTextTree = [];
  const nodeContext = getContext()!;

  // Children render into subtree using text collector...

  nodeContext.meta.file = {
    path: props.path,
    serialize: (options) => printTree(subtree, options),
  };

  return props.children;
}

Core's Role in Output Assembly

The top-level render() function in core:

  1. Evaluates the component tree (all reactive updates settle)
  2. Walks the rendered tree looking for context.meta.file and context.meta.directory
  3. Calls meta.file.serialize(options) for each file to produce output
  4. Assembles the OutputDirectory structure

Core is completely generic β€” it knows nothing about text vs. data vs. definitions.

Mixed-Domain Output

A single Output can contain files with different model types and collection strategies in the same render pass:

<Output>
  <SourceFile path="main.py">{/* Python code */}</SourceFile>

  <DataFile path="config.json" format="json">
    <Object>
      <Property name="version">
        <Scalar>1.0</Scalar>
      </Property>
    </Object>
  </DataFile>

  <DefinitionScope domain="graphql">
    <DefinitionFile path="schema.graphql">
      <ObjectType name="User" />
    </DefinitionFile>
  </DefinitionScope>
</Output>

5. Format-Specific Components

Format packages (@alloy-js/yaml, @alloy-js/xml, @alloy-js/toml) export components that carry format-specific information. These fall into two categories.

Rendering Hints

Rendering hints control how data is represented in a particular format without changing what the data is. They are context providers wrapping generic content. The matching renderer reads the context; non-matching renderers ignore it and process children as-is. Data is never lost.

Examples:

  • YAML string style: Yaml.LiteralStyle wraps a Scalar. The YAML renderer uses | block style. Other renderers output the string normally.

    <Yaml.LiteralStyle>
      <Scalar>hello\nworld</Scalar>
    </Yaml.LiteralStyle>
  • YAML block vs. flow: Yaml.FlowStyle wraps an Object or Array. The YAML renderer uses inline {}/[] syntax. Other renderers output the structure normally.

  • TOML inline table: Toml.InlineTable wraps an Object. The TOML renderer uses {key = value} syntax instead of a [table] header. Other renderers output the object normally.

  • XML attributes: Xml.AsAttribute wraps Property nodes. The XML renderer emits them as element attributes. Other renderers output the properties normally.

    <Object>
      <Xml.AsAttribute>
        <Property name="id">
          <Scalar>42</Scalar>
        </Property>
      </Xml.AsAttribute>
      <Property name="body">
        <Object>...</Object>
      </Property>
    </Object>

    The same component tree produces format-appropriate output from each renderer. The XML renderer honors the Xml.AsAttribute hint; the JSON renderer ignores it and emits the property normally. The data is identical β€” only the representation changes:

    XML:

    <root id="42"><body>...</body></root>

    JSON:

    {"id": 42, "body": {...}}
  • XML element naming: Xml.ItemElement wraps an Array to specify the element name for each item.

  • XML namespaces: Xml.Namespace wraps elements to bind a namespace prefix.

Multiple format hints can wrap the same content without conflict β€” each renderer reads only the contexts it understands. See Appendix F for how mismatched components are handled.

Format Metadata

Format metadata is information about the format itself: XML processing instructions (<?xml ...?>), YAML document markers (---), TOML comments, DTD declarations. These have no generic equivalent. When a non-matching renderer encounters them, it produces no output for that subtree β€” this is expected behavior.

Shared References (Refkeys)

YAML anchors/aliases, JSON $ref pointers, and similar cross-reference mechanisms are serialization-specific representations of a generic concept: shared references. Alloy already provides this via refkeys.

Rather than requiring format-specific anchor/alias components, a format package provides a rendering context that controls how refkeys are serialized. The data model itself uses refkeys; the format decides how to represent them.

The Pattern

Format-specific components are context providers following two rules:

  1. Rendering hints wrap generic content. Matching renderer reads context; others ignore it. Data is always preserved.
  2. Format metadata may have no generic content. Non-matching renderers produce no output. This is intentional.

6. Open Decisions / Questions

  • What is the shape of the ModelCollector interface? The text collector and data model collector have very different needs (text appends strings; data model builds structure from components). The interface must be narrow enough to be generic but expressive enough that collectors aren't fighting it.
  • How does the collector interact with reactive re-rendering? When a dependency changes and a subtree is re-evaluated, the collector needs to replace (not append) the affected nodes. The current code handles this by index replacement on the RenderedTextTree array β€” the collector interface needs an equivalent mechanism.
  • How should domain-scoped intrinsics integrate with the JSX type system across packages?
  • Should file components infer domain/format from file extension, require it explicitly, or support both with sensible defaults?
  • What methods should DefinitionState require? At minimum: register(), resolve(), serialize(), but are there other common operations?
  • How does multi-file coordination work with DefinitionScope? Does it automatically merge states from multiple files, or do domain packages implement merging logic?
  • How do devtools visualize component-driven collection where there's no intermediate tree to inspect? Can we expose the DefinitionRegistry state for inspection?
  • What is the testing story? The current test infrastructure is text-oriented. Data model and component-driven testing need different assertion utilities.
  • How do refkeys work in the data model? Currently refkeys are converted to source-file references via SourceFileContext.reference. In a data model, what does a refkey produce? (JSON $ref? Repeated values?)
  • What are the backwards compatibility implications? Moving text-specific code from core to @alloy-js/source is a breaking change for existing users.

Appendices

A. Glossary

  • Component tree: the tree produced by JSX (Children, ComponentCreators, primitives). The input to the conceptual pipeline.
  • Model: a domain-specific intermediate representation (e.g., DataModelTree, RenderedTextTree, GraphQLSchema). May be a tree or a graph/flat collection. Some models support multiple renderers; others are coupled to a single renderer. Built by a model collector or by component-driven collection.
  • Document tree: a renderer-specific serialization structure (e.g., Prettier.Doc, YAML node tree, GraphQL DocumentNode). The intermediate step between model and output text.
  • Output text: the final string/file contents. Produced by the lazy serialization convention.
  • Evaluator-driven collection: the evaluator encounters component results and delegates to a ModelCollector that assembles them into a tree structure. Used when output structure mirrors component tree structure. See Two Collection Patterns and Appendix H.
  • Component-driven collection: components push into shared state via context as they execute. The evaluator just runs components; no collector needed. Used when output is a graph or flat namespace. See Two Collection Patterns and Appendix H.

B. Current Alloy Structure

These are the parts of the current codebase that the core design refactors:

  • renderTree builds a RenderedTextTree from Children, printTree produces a Prettier.Doc, and render converts that into OutputDirectory/files. See packages/core/src/render.ts.
  • JSX runtime returns a ComponentCreator thunk, not a ReactElement-like object. The renderer invokes the thunk during traversal. See packages/core/src/jsx-runtime.ts and packages/core/src/runtime/component.ts.
  • Formatting is handled by intrinsics and print hooks, which are text-specific. See packages/core/src/runtime/intrinsic.ts and packages/core/src/print-hook.ts.
  • File structure is defined by Output and SourceFile, which attach metadata into the render context. See packages/core/src/components/Output.tsx and packages/core/src/components/SourceFile.tsx.
  • Reactivity is driven by @vue/reactivity and updates the rendered tree when dependencies change. See packages/core/src/render.ts.

C. Detailed Pipeline Examples

See Conceptual Pipeline for the generic form.

Source Code (Evaluator-Driven)

Component tree β†’ RenderedTextTree (via text collector) β†’ Prettier.Doc β†’ string

Data/Config Formats (Evaluator-Driven)

Component tree β†’ DataModelTree (via data model collector) β†’ [format] document tree β†’ string

Where [format] is one of: JSON (value/object/array nodes), YAML (scalar/sequence/mapping), TOML (tables, arrays-of-tables, key paths), or XML (elements/attributes).

Schema/Definition Languages (Component-Driven)

Component tree (components register into shared state) β†’ domain model β†’ domain document tree β†’ string

For GraphQL: SchemaState β†’ DocumentNode (SDL) or introspection JSON. For SQL: SqlSchemaState β†’ DDL statements. For Protobuf: ProtoState β†’ .proto syntax.

D. Data Model Component Guarantees

The generic data components (<Object>, <Array>, <Scalar>, <Property> from @alloy-js/data-model) explicitly document which semantic properties they preserve across renderers. Guarantees are based on the lowest common denominator of renderer support: if a component guarantees a property, every format renderer must honor it. If not, renderers are free to handle it as the target format requires.

The set of guarantees is driven by what all supported data formats can uphold. A guarantee that any renderer would need to violate should not be made.

Example β€” ordering. An <Array> guarantees item order is preserved: every target format supports ordered sequences, so renderers must respect it. An <Object> does not guarantee key order: JSON specifies that key order is not significant, and TOML requires key reordering to produce valid output (bare key-value pairs before sub-tables). Because not all renderers can preserve key order, the generic <Object> makes no such promise. A user who needs ordered keys reaches for a format-specific component.

E. Comparison to React/Vue Architecture

Where it aligns:

  • React splits "describe the tree" from "render to a host" with pluggable host configs (react-dom, react-native, react-test-renderer). The ModelCollector plays the same role: different collectors tell the same generic evaluator how to construct different model trees.
  • Vue/vuerx: Alloy already follows the "evaluate JSX to values or thunks using reactive effects" style, making DataModelTree building feel natural.

Where it differs:

  • React builds a ReactElement tree then reconciles to a UI host. Alloy does not build a ReactElement equivalent; JSX yields a ComponentCreator thunk and renderTree executes it.
  • React's host config is a rich interface (~30 methods) designed for long-lived UI with mutation, insertion, and removal. Alloy's ModelCollector is simpler because it builds static artifacts, not a live instance graph. There is no commit phase or reconciliation.
  • Vue's runtime is UI-oriented; Alloy uses Vue reactivity for build-time recomputation, not UI updates.

F. Renderer Mixing and Error Behavior

  • Components rely on context assumptions rather than negotiated capabilities (existing behavior).
  • Mixing incompatible components can throw at runtime (e.g., TS components requiring a TS lexical scope) or render syntactically invalid content (e.g., a Python import inside a TS file).
  • Format-specific rendering hints mitigate this for the data model: they wrap generic content, so non-matching renderers ignore the hint and still process children. Risk is limited to format metadata components (no generic content).
  • Because intrinsic elements are domain-scoped, cross-domain intrinsic misuse is largely prevented by the type system rather than runtime checks.
  • The existing "silently ignore, don't fail explicitly" philosophy is retained. No capability negotiation system is needed.

G. Additional Use Cases

Because renderers preserve the model's data (they may choose representation but do not alter semantic content):

  • Multi-format config output from one data model (e.g., YAML for humans, JSON for machines)
  • Canonicalization pipelines: normalize key ordering or value forms once, then render to multiple formats
  • Schema-based validation: validate the model tree against a JSON Schema or custom rules before rendering
  • Testing snapshots: serialize the model tree to a stable debug format for snapshot tests
  • Devtools visualization: inspect the model tree directly to diagnose output issues

H. Choosing a Collection Pattern

See Two Collection Patterns for full definitions. This appendix provides a quick decision guide.

Use evaluator-driven collection when:

  • Output structure mirrors component tree structure
  • Positional relationships matter (text, nested data)
  • Children belong to their parents in a tree hierarchy
  • Reactive subtree replacement is important

Use component-driven collection when:

  • Output is a graph with cross-references
  • Components register into a flat namespace or shared registry
  • Definition order doesn't matter (or is normalized post-collection)
  • Components need to reference each other non-hierarchically

I. Migration Impact

This appendix summarizes what changes for each existing package.

@alloy-js/core β€” becomes a pure runtime package. Text-specific code is extracted: RenderedTextTree types, print hooks, formatting intrinsics (<indent>, <group>, etc.), content tracking (isEmpty, childrenWithContent, createContentSlot), printTree, and SourceFile all move to @alloy-js/source. The Prettier dependency is removed from core. Everything that remains β€” JSX runtime, reactivity, context, Output, SourceDirectory, diagnostics, devtools, refkeys β€” is already generic. This is a breaking change: code that imports text-specific symbols from @alloy-js/core must update imports to @alloy-js/source.

@alloy-js/source (new) β€” receives all text-specific code extracted from core. This is not new functionality; it is a direct extraction of the current renderTree/printTree pipeline into its own package. Existing behavior is preserved.

Language packages (@alloy-js/typescript, @alloy-js/python, @alloy-js/csharp, etc.) β€” remain component libraries. Their dependency on @alloy-js/core for formatting primitives (SourceFile, <indent>, content tracking) changes to a dependency on @alloy-js/source. No component logic changes; only import paths shift.

@alloy-js/graphql β€” currently works around the coupled renderTree by calling it for its side effect and discarding the RenderedTextTree. After the refactor, renderTree accepts no collector for component-driven domains, making the workaround unnecessary. @alloy-js/graphql can adopt DefinitionFile and DefinitionScope from @alloy-js/definitions for multi-file coordination, and implement DefinitionState for its SchemaState. Existing renderSchema behavior is preserved.

@alloy-js/data-model (new) β€” new package providing DataFile, DataModelTree, and generic data components (<Object>, <Array>, <Scalar>, <Property>). No migration needed; this is additive.

@alloy-js/definitions (new) β€” new package providing DefinitionScope, DefinitionFile, DefinitionRegistry<T>, and DefinitionState. No migration needed; this is additive. Existing component-driven packages like @alloy-js/graphql can adopt it incrementally.

Format packages (@alloy-js/json, @alloy-js/yaml, @alloy-js/toml, @alloy-js/xml) β€” new renderer packages. No migration needed; these are additive.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions