Table of Contents

Core concepts

This page describes the moving parts of Bodu.Text.Yaml - the serializer, the converter model, the two DOMs, and the reader/writer seam. The library keeps the Bodu serializer family architecture - the same serializer facade, the shared attribute family, naming policies, serialization callbacks, converter factories, [ExtensionData], [Constructor], and [Required] that the TOML and Bencode siblings carry - and tunes only what YAML's value model warrants; those YAML-specific points are called out below.

Part of the Text & Serialization topic.

The serializer

The static YamlSerializer is the high-level entry point. Serialize writes an object graph to YAML; Deserialize<T> binds YAML back to a type. Its surface is string text, UTF-8 bytes, and the same stream facade its siblings ship:

  • Serialize<T>(T value, YamlSerializerOptions? options = null) → string, and Serialize(object? value, Type inputType, …) → string for a runtime-typed value; Serialize<T>(IBufferWriter<byte> destination, T value, …) writes UTF-8 bytes; SerializeAsync<T>(Stream destination, T value, …) writes them to a stream.
  • Deserialize<T>(string yaml, …) → T? and Deserialize<T>(ReadOnlySpan<byte> utf8Yaml, …) → T?, plus Deserialize(ReadOnlySpan<byte> utf8Yaml, Type returnType, …) → object?; Deserialize<T>(Stream source, …) and DeserializeAsync<T>(Stream source, …) read a stream to its end.

The stream overloads are buffered in full - the document is rendered or read into memory and only the stream copy is asynchronous - so they are conveniences over the span entry points rather than incremental parsers.

Deserialize<T> returns a nullable T? - a top-level null scalar binds to null. Use the null-forgiving ! where you know the document is non-null.

using Bodu.Text.Yaml;

string yaml = YamlSerializer.Serialize(new ServerConfig { Host = "localhost", Port = 8080 });
ServerConfig config = YamlSerializer.Deserialize<ServerConfig>(yaml)!;

Options

YamlSerializerOptions configures the serializer. The real properties are:

Property Effect
PropertyNamingPolicy The NamingPolicy applied to member names (null keeps the declared name).
IncludeFields When true, public fields participate alongside properties.
DefaultIgnoreCondition The serializer-wide IgnoreCondition applied on write: Never (default), WhenWritingNull, or WhenWritingDefault.
WriteEnumsAsStrings When true (the default), enums write as member-name strings; when false, as integers.
PropertyNameCaseInsensitive When true, mapping keys match members case-insensitively on read.
SpecVersion YamlSpecVersion - V1_2 (default) or V1_1.
NumberHandling YamlNumberHandling - Strict (default) or AllowFloatToInteger.
DuplicateKeyBehavior YamlDuplicateKeyBehavior - Throw (default), UseFirst, or UseLast.
MergeKeyBehavior YamlMergeKeyBehavior - Expand (default), Disabled, or PreserveAsNormalKey.
UnmappedMemberHandling UnmappedMemberHandling - Skip (default) or Disallow.
PreferredObjectCreationHandling ObjectCreationHandling - Replace (default) or Populate.
MaxDepth Maximum nesting depth; default 64.
Converters The ordered list of custom YamlConverter instances.

Options can also be constructed from a YamlSerializerDefaults scenario preset: General (the plain defaults) or Web (camel-case naming with case-insensitive matching).

An options instance becomes read-only the first time it is used - or eagerly via MakeReadOnly() (IsReadOnly reports the state) - and then caches its resolved converters and type metadata. Configure one options object and reuse it across many operations; constructing fresh options per call discards the caches.

Converters and resolution

YAML node model

A YamlConverter<T> converts one type. It reads through the Utf8YamlReader token cursor (aliases and merge keys already resolved) and writes through the Utf8YamlWriter:

public abstract T Read(ref Utf8YamlReader reader, Type typeToConvert, YamlSerializerOptions options);
public abstract void Write(Utf8YamlWriter writer, T value, YamlSerializerOptions options);

For a given type the serializer resolves a converter by checking, in order: a member-level [Converter(...)] attribute, a type-level one, the first matching converter in options.Converters, and finally the built-ins. On the write path it looks up the value's runtime type first and then the declared type, so a converter registered for a concrete type still fires for a value held in an object or interface member. CanConvert on a YamlConverter<T> defaults to an exact type match (typeof(T) == typeToConvert), so a converter does not apply to subclasses unless you override CanConvert - or derive YamlConverterFactory to serve a family of types, the pattern the built-in nullable/enum/collection/dictionary converters and the public YamlStringEnumConverter use.

The non-generic YamlConverter base exists only so the collection can hold mixed converters; you always derive from the generic YamlConverter<T> or the factory. Register every options-level converter before first use - the collection is guarded and throws once the options freeze. To shape members without a custom converter, use the naming policy, the shared attribute family ([PropertyName], [Ignore], [Converter], …), and the options flags.

The document object models

When you do not want a model, two DOMs serve the same documents:

  • Mutable - YamlNode / YamlObject / YamlArray / YamlValue. YamlNode.Parse(string) builds the tree (returning null for a null/empty document); index into it with [int] / [string], cast with AsObject / AsArray / AsValue, build scalars with YamlValue.Create(…) (overloads for string / long / double / bool - the four scalar kinds), read them back with GetValue<T>() (a direct return for the stored type, else a Convert.ChangeType coercion), and write it back with ToYamlString() (or WriteTo(Utf8YamlWriter)). YamlObject preserves insertion order; a node may appear at most once in a tree.
  • Read-only - YamlDocument / YamlElement / YamlProperty. A low-allocation view over a parsed buffer, walked through RootElement. YamlDocument is IDisposable - a document you parse is caller-owned, so dispose it when finished; an element read after disposal throws ObjectDisposedException. ParseAllDocuments returns every document in a multi-document stream, each independently caller-owned. Parsing options come from YamlDocumentOptions (SpecVersion, DuplicateKeyBehavior, MergeKeyBehavior, MaxDepth).

Typed access on a YamlElement goes through GetString / GetInt64 / GetDouble / GetBoolean (each throwing InvalidOperationException on a kind mismatch), with GetProperty (throwing KeyNotFoundException) / TryGetProperty, EnumerateMapping, GetSequenceLength / EnumerateSequence, the [int] indexer, ValueKind / ScalarStyle, and a ToString() that renders the scalar text or the container kind name.

The buffered reader and the writer

Utf8YamlReader and Utf8YamlWriter are forward-only ref struct token machines, and the surface every converter receives. Two YAML-specific points:

  • The reader is buffered, not a single-pass streaming scanner. Because YAML's anchors, aliases, merge keys, and indentation context require a resolved tree, the constructor copies the source and parses it fully into an in-memory node store; Read() then walks that store in document order. It exposes the current token through TokenType (a YamlTokenType), the typed getters GetString() / GetInt64() / GetDouble() / GetBoolean() (each throwing on a kind mismatch, with GetDouble() accepting an integer token too), CurrentDepth, and ValueTextEquals(ReadOnlySpan<byte>) for an allocation-free key comparison. YamlReaderOptions mirrors the relevant options (SpecVersion, DuplicateKeyBehavior, MergeKeyBehavior, MaxDepth). In this respect the reader is the analogue of the buffered Toml document cursor rather than the streaming Utf8TomlReader.
  • The writer emits block-style YAML. Collections are always written in block form for readability; only an empty container falls back to flow [] / {} so it round-trips as empty rather than null. A string is emitted plain when that is unambiguous and double-quoted (with escapes) otherwise - there is no public scalar-style control on the write path. The writer enforces a well-formed call sequence: a value without a pending key, a mismatched WriteEnd…, or a second document root each throw InvalidOperationException. YamlWriterOptions sets IndentSize (default 2, capped at 16), MaxDepth, and NewLine (only "\n" or "\r\n").

Value mapping

The serializer maps the BCL types it can represent natively:

  • string / char / Guid / Uri and the integer family → string or integer scalars; double / float → float scalars (with .nan / .inf / -.inf); bool → a Boolean scalar; null and a null Nullable<T> → the null scalar.
  • decimal → a quoted exact-text string; DateTime / DateTimeOffset → a round-trip ISO-8601 string; TimeSpan → its invariant string. None of these is a native YAML kind, so each travels as a string and parses back with CultureInfo.InvariantCulture.
  • Enums → member-name strings (or integers when WriteEnumsAsStrings is false); read back by name (case-insensitively) or by integer.
  • Collections → sequences; dictionaries and plain objects → mappings. A dictionary keeps insertion order; an object writes its properties first (reflection order) then public fields when IncludeFields is set.
  • An object-typed member writes its runtime type's form. On read an object target binds to a loosely-typed graph - Dictionary<string, object?> for a mapping, List<object?> for a sequence, and bool / long / double / string / null for scalars - not a YamlElement. (A custom converter's Read, by contrast, works through the Utf8YamlReader token cursor.)

The full per-type catalog - including the radix forms accepted on read and the quoting rules - is in the built-in converter catalog.

Multi-document streams

A YAML stream may carry several documents separated by --- and optionally terminated by .... ParseAllDocuments returns every document; the single-document Parse and the serializer's Deserialize<T> read the first document only.

Errors

A malformed document raises YamlFormatException, carrying LineNumber, ColumnNumber, and Offset. A document that parses but cannot bind - a kind mismatch, a value the format cannot represent, or an unmapped member under Disallow - raises YamlSerializationException, carrying Offset, LineNumber, ColumnNumber, and a dotted member Path.

Where to go next