Heterogeneous strategies¶
A heterogeneous collection is a list or mapping whose scalar values do not all share a single language-level type – for example a JSON array [1, "hello"], which mixes an integer and a string.
Dynamically typed source data routinely contains such collections, but many target languages have no single literal that can hold them.
The :heterogeneous-strategy: option (on both the literalizer and literalizer-call directives – see sphinx-literalizer) selects how sphinx-literalizer renders these collections.
This page explains what each strategy emits, when each is appropriate, and which languages expose which strategies.
The default: auto¶
:heterogeneous-strategy: defaults to auto.
auto renders the input with its natural representation first – so homogeneous and genuinely map-shaped data keep their native form, byte-identical to naming no strategy at all – and only falls back to a representational strategy when the natural rendering fails because the data is heterogeneous.
Genuinely unrepresentable input still raises after the fallbacks are exhausted, so the “fail loud” safety is preserved exactly where it matters.
auto makes a representation choice implicitly: record vs tuple vs tagged_enum changes the shape of the generated API.
An author who wants a specific representation for pedagogical or clarity reasons should set :heterogeneous-strategy: explicitly rather than rely on the default.
To keep a mixed-scalar collection a hard build failure instead, set :heterogeneous-strategy: error explicitly:
.. literalizer:: data.json
:language: rust
:heterogeneous-strategy: error
with data.json containing [1, "hello"] reports an error on the directive describing the heterogeneous scalar types.
Without that explicit error, the same input falls back through the configured precedence under auto.
Choosing a strategy¶
auto(the default)Let sphinx-literalizer choose. The input is rendered with its natural representation first, so homogeneous and genuinely map-shaped data keep their native form, and a strategy is only applied if that natural rendering fails because the data is heterogeneous. See Letting the directive choose: auto below.
errorKeep strict typing. Appropriate when mixed-scalar collections in the source data are a mistake you want surfaced rather than rendered. This was the default before sphinx-literalizer defaulted
:heterogeneous-strategy:toauto; it must now be set explicitly.tagged_enum/object_variant/union_type/interface/variantWrap each value in a generated sum type so a list (or list-shaped mapping) of mixed scalars round-trips. Appropriate when the collection is genuinely a sequence of “one of several scalar types”. These differ only in the host language’s idiom for a tagged union; the modeling is the same.
recordRender a mapping whose values mix scalars and containers as a generated
structdeclaration plus a matching struct literal. Appropriate when the mapping is record-shaped (non-empty, string keys) – each key becomes a typed field, so values may legitimately differ per field.tupleRender a fixed-length list of mixed scalars as the language’s native tuple. Appropriate when position, not iteration, carries meaning and no extra preamble declaration is wanted.
Strategies are not mutually exclusive across data shapes: the same language may pick record for a mapping and one of the sum-type strategies for a list.
Each language exposes only the subset listed in Per-language support below.
Letting the directive choose: auto¶
A realistic project rarely has one strategy that fits every input.
A record-shaped dict needs record; a genuinely map-shaped dict must stay a map; a list of mixed scalars needs a sum type or tuple.
:heterogeneous-strategy: auto removes that per-input decision.
auto renders the input with its natural representation first.
If that succeeds – as it does for homogeneous data and for genuinely map-shaped mappings – the native output is used unchanged, so auto never promotes a plain map to a generated record.
Only if the natural rendering fails because the data is heterogeneous does auto retry, trying each strategy the target language supports in a configured precedence and using the first that represents the data.
The precedence is the literalizer_heterogeneous_strategy_precedence configuration value (see sphinx-literalizer), restricted per directive to the strategies the target language exposes.
It defaults to record, tuple, tagged_enum, object_variant, variant, union_type, interface; error is never a fallback because it is the failure auto is recovering from.
For example, with _examples/auto_record.json:
[{"id": 1, "desc": "x", "blocks": [1, 2]}]
the natural Rust map rendering fails (a HashMap cannot hold mixed value types), so auto falls back to record:
.. literalizer:: _examples/auto_record.json
:language: rust
:heterogeneous-strategy: auto
Record0 { id: 1, desc: "x", blocks: vec![1, 2] },
The same directive with a homogeneous or map-shaped input emits the native HashMap instead, with no fallback applied.
Skipping unrepresentable inputs¶
Some inputs cannot be represented in some languages even with auto – for instance a shape no strategy the language exposes can model.
By default this fails the build, which is correct when every language must render every input.
When a single canonical input is rendered across several languages (typically a Jinja loop over a language list), failing the whole build forces the data-shape knowledge – “skip Rust for this one” – into the template or prose.
The :skip-if-unrepresentable: flag (on both directives) keeps that knowledge in the directive: when the input cannot be represented in the target language, including after auto exhausts its precedence, the directive emits no node instead of raising.
.. literalizer:: data.json
:language: rust
:heterogeneous-strategy: auto
:skip-if-unrepresentable:
Without :skip-if-unrepresentable: the same unrepresentable input is reported as an error on the directive, as before.
Worked examples¶
Each example uses :include-preamble: so the generated declaration is shown alongside the literal.
Without that flag only the literal (the second block) is emitted, and without :include-delimiters: the literal carries no surrounding collection delimiters.
Most examples render _examples/mixed_scalars.json:
[1, "hello"]
The record and tuple examples use their own input files, shown inline.
tagged_enum (Rust)¶
.. literalizer:: _examples/mixed_scalars.json
:language: rust
:heterogeneous-strategy: tagged_enum
:include-preamble:
enum Value {
I32(i32),
Str(&'static str),
}
Value::I32(1),
Value::Str("hello"),
Empty containers in tagged-enum output¶
Every element of a Rust Vec must have the same type. In a mixed
collection, tagged_enum therefore wraps every element in the generated
Value enum. An empty map especially needs its Value::Map wrapper:
it has no entries from which Rust could infer key and value types, while the
variant fixes its type as HashMap<&'static str, Value>.
For example, _examples/tagged_enum_empty_map.json contains:
[
{},
"tech"
]
With :include-preamble:, the directive emits the required HashMap
import and surrounding Value enum as well as the wrapped literal:
.. literalizer:: _examples/tagged_enum_empty_map.json
:language: rust
:heterogeneous-strategy: tagged_enum
:include-delimiters:
:include-preamble:
use std::collections::HashMap;
enum Value {
Str(&'static str),
Map(HashMap<&'static str, Value>),
}
vec![
Value::Map(HashMap::new()),
Value::Str("tech"),
]
The generated pieces fit into a complete program like this:
use std::collections::HashMap;
enum Value {
Str(&'static str),
Map(HashMap<&'static str, Value>),
}
fn main() {
let values: Vec<Value> = vec![
Value::Map(HashMap::new()),
Value::Str("tech"),
];
assert!(matches!(&values[0], Value::Map(entries) if entries.is_empty()));
assert!(matches!(&values[1], Value::Str("tech")));
}
Keep :include-preamble: when the rendered block must be self-contained.
If imports and the Value declaration already live elsewhere, omit it and
render only the expression. When tagged values are not the API you want,
choose a representation that matches the data: record for a non-empty
object with stable fields, tuple for a fixed-shape sequence, or error
to reject heterogeneous input. An empty map has no fields, so it cannot
itself become a generated record.
record (Go)¶
With _examples/record.json:
[{"name": "a", "count": 1, "items": [1, 2]}]
.. literalizer:: _examples/record.json
:language: go
:heterogeneous-strategy: record
:include-preamble:
package main
type Record0 struct {
Name string
Count int
Items []int
}
Record0{Name: "a", Count: 1, Items: []int{1, 2}},
Nested map fallback (Rust)¶
record keeps a uniform outer record even when maps nested under the same
field have incompatible sibling shapes. The nested level falls back to the
language’s native map representation and value carrier; no :json-type: or
additional directive option is required. This is useful for test-case data
such as _examples/record_nested_maps.json:
[
{
"name": "test_1",
"input": {"type": "create", "pr_id": "pr_1", "draft": true},
"expected": {"pr_id": "pr_1", "status": "draft"}
},
{
"name": "test_2",
"input": {"type": "publish", "pr_id": "pr_1"},
"expected": {"error": "invalid_operation"}
}
]
The same record path is available for C#, C++, Go, Java, Kotlin, Rust, and
Scala. For example, Rust remains standard-library-only:
.. literalizer:: _examples/record_nested_maps.json
:language: rust
:heterogeneous-strategy: record
:include-delimiters:
:include-preamble:
use std::collections::HashMap;
enum Value {
Str(&'static str),
Bool(bool),
}
struct Record0 {
name: &'static str,
input: HashMap<&'static str, Value>,
expected: HashMap<&'static str, Value>,
}
vec![
Record0 { name: "test_1", input: HashMap::from([("type", Value::Str("create")), ("pr_id", Value::Str("pr_1")), ("draft", Value::Bool(true))]), expected: HashMap::from([("pr_id", Value::Str("pr_1")), ("status", Value::Str("draft"))]) },
Record0 { name: "test_2", input: HashMap::from([("type", Value::Str("publish")), ("pr_id", Value::Str("pr_1"))]), expected: HashMap::from([("error", Value::Str("invalid_operation"))]) },
]
Stable fallback map typing (Rust)¶
By default that fallback map’s value type follows the data: when every
widened scalar in the input shares one type, that concrete type is
spelled.
This is the tightest type the input admits, but it is derived per input
file, so two files sharing one record shape can declare the field
differently.
With _examples/record_map_value_typing.json, where the two
attributes maps have different keys and so are widened:
[
{
"name": "row_1",
"attributes": {"region": "emea", "tier": "gold"}
},
{
"name": "row_2",
"attributes": {"region": "apac", "zone": "z1"}
}
]
the attributes values are all strings, so the field is a
HashMap<&'static str, &'static str>:
.. literalizer:: _examples/record_map_value_typing.json
:language: rust
:heterogeneous-strategy: record
:record-shape-names: name,attributes=Row
:include-preamble:
use std::collections::HashMap;
struct Row {
name: &'static str,
attributes: HashMap<&'static str, &'static str>,
}
Row { name: "row_1", attributes: HashMap::from([("region", "emea"), ("tier", "gold")]) },
Row { name: "row_2", attributes: HashMap::from([("region", "apac"), ("zone", "z1")]) },
:record-map-value-typing: wide instead always spells the strategy’s
value carrier, however uniform this file’s scalars happen to be:
.. literalizer:: _examples/record_map_value_typing.json
:language: rust
:heterogeneous-strategy: record
:record-shape-names: name,attributes=Row
:record-map-value-typing: wide
:include-preamble:
use std::collections::HashMap;
enum Value {
Str(&'static str),
}
struct Row {
name: &'static str,
attributes: HashMap<&'static str, Value>,
}
Row { name: "row_1", attributes: HashMap::from([("region", Value::Str("emea")), ("tier", Value::Str("gold"))]) },
Row { name: "row_2", attributes: HashMap::from([("region", Value::Str("apac")), ("zone", Value::Str("z1"))]) },
Write one directive per data file with the same
:record-map-value-typing: wide, and every directive declares
Row’s attributes field identically, so one file’s literals
compile against another file’s struct.
The carrier’s own member set stays data-derived, so a file whose widened
values span more types still declares more variants.
The option is available for C++, Go, and Rust.
tuple (Rust)¶
With _examples/tuple.json:
[1, "hello", true]
renders (no preamble – the tuple is a native literal):
.. literalizer:: _examples/tuple.json
:language: rust
:heterogeneous-strategy: tuple
1,
"hello",
true,
Candidate-facing C++14¶
C++14 has two native, candidate-facing representations for heterogeneous
input. Use tuple for a fixed-shape sequence; it emits
std::make_tuple(...) without exposing a LiteralizerVariant wrapper:
.. literalizer:: _examples/tuple.json
:language: cpp
:language-version: cpp14
:heterogeneous-strategy: tuple
:include-preamble:
#include <initializer_list>
#include <string>
#include <vector>
#include <tuple>
1,
"hello",
true,
This also composes through a homogeneous outer sequence. With
_examples/nested_tuple.yaml:
---
- [1, Mainframe1]
the same strategy produces a standard std::vector<std::tuple<...>>:
.. literalizer:: _examples/nested_tuple.yaml
:language: cpp
:language-version: cpp14
:heterogeneous-strategy: tuple
:include-preamble:
#include <initializer_list>
#include <string>
#include <vector>
#include <tuple>
std::make_tuple(1, "Mainframe1"),
For object-shaped input, use record and give the generated struct a
domain name with :record-struct-name-prefix::
.. literalizer:: _examples/record.json
:language: cpp
:language-version: cpp14
:heterogeneous-strategy: record
:record-struct-name-prefix: Candidate
:include-preamble:
#include <initializer_list>
#include <string>
#include <map>
#include <vector>
#include <cstddef>
#include <memory>
#include <utility>
struct Value {
private:
struct Holder {
Holder() = default;
Holder(const Holder&) = delete;
Holder(Holder&&) = delete;
Holder& operator=(const Holder&) = delete;
Holder& operator=(Holder&&) = delete;
virtual ~Holder() = default;
};
template <typename T> struct TypedHolder : Holder {
explicit TypedHolder(T value) : value_(std::move(value)) {}
T& get() { return value_; }
const T& get() const { return value_; } // NOLINT(modernize-use-nodiscard)
private:
T value_;
}; // TypedHolder
static std::shared_ptr<Holder> make_holder(const char* value) {
return std::make_shared<TypedHolder<std::string>>(value);
} // make_holder string
template <typename T> static std::shared_ptr<Holder> make_holder(T value) {
return std::make_shared<TypedHolder<T>>(std::move(value));
} // make_holder generic
std::shared_ptr<Holder> value_;
public:
Value() : value_(new TypedHolder<std::nullptr_t>(nullptr)) {}
template <typename T> explicit Value(T value) : value_(make_holder(std::move(value))) {}
template <typename T> bool is() const { // NOLINT(modernize-use-nodiscard)
return dynamic_cast<TypedHolder<T>*>(value_.get()) != nullptr;
}
template <typename T> T& get() {
return static_cast<TypedHolder<T>*>(value_.get())->get();
} // get
template <typename T> const T& get() const {
return static_cast<const TypedHolder<T>*>(value_.get())->get();
} // get const
};
struct Candidate0 { std::string name; int count{}; std::vector<int> items; };
Candidate0{"a", 1, {1, 2}},
object_variant (Nim)¶
.. literalizer:: _examples/mixed_scalars.json
:language: nim
:heterogeneous-strategy: object_variant
:include-preamble:
type
ValueKind = enum
vkInt, vkStr
Value = object
case kind: ValueKind
of vkInt: intVal: int
of vkStr: strVal: string
Value(kind: vkInt, intVal: 1),
Value(kind: vkStr, strVal: "hello")
union_type (Dhall)¶
.. literalizer:: _examples/mixed_scalars.json
:language: dhall
:heterogeneous-strategy: union_type
:include-preamble:
let Value = < Int : Integer | Str : Text > in
Value.Int +1,
Value.Str "hello",
interface (V)¶
.. literalizer:: _examples/mixed_scalars.json
:language: v
:heterogeneous-strategy: interface
:include-preamble:
interface IVal {}
IVal(1),
IVal('hello'),
variant (Mojo)¶
.. literalizer:: _examples/mixed_scalars.json
:language: mojo
:heterogeneous-strategy: variant
:include-preamble:
from std.utils.variant import Variant
comptime Value = Variant[Int, String]
Value(1),
Value(String("hello")),
Per-language support¶
error is available for every language; auto is the default.
The table below lists the additional strategies each language exposes.
Languages not listed support only error (set explicitly) and auto (which, with no representational strategy to fall back to, behaves like error for heterogeneous input).
Strategy |
Languages |
Emits |
|---|---|---|
|
Rust |
A generated tagged |
|
C#, C++, Go, Java, Kotlin, Rust, Scala |
A generated |
|
C++, Rust |
The language’s native fixed-length tuple. |
|
Nim |
A generated Nim object variant plus tagged values. |
|
Dhall |
A generated Dhall union type plus tagged values. |
|
V |
A generated V |
|
Mojo |
A |
Selecting a strategy a language does not support is an error rather than a silent fallback, so a typo such as :heterogeneous-strategy: tagged_enum on a Go directive fails loudly.
This matrix tracks the upstream literalizer release pinned by sphinx-literalizer; new languages and strategies are announced in the Changelog.