Recursive AI

Write a custom YAML converter in C#

OneYaml is a free, open-source YAML library for .NET from Recursive AI. MIT or Apache-2.0, no native dependencies.

Store a .NET Version as one YAML scalar, such as 2.1.0.

Terminal
dotnet add package OneYaml

The package targets .NET 8 and .NET 10; the download runs on .NET 10.

C#
using OneYaml;
 
var options = new YamlSerializerOptions
{ Converters = { new VersionConverter() } };
Console.Write(YamlSerializer.Serialize(
    new Version(2, 1, 0), options));
 
sealed class VersionConverter
    : YamlConverter<Version>
{
    public override Version Read(
        ref Utf8YamlReader reader, Type type,
        YamlSerializerOptions options) =>
        Version.TryParse(reader.GetString(),
            out var value) ? value
            : throw reader.CreateException(
                "Bad version.");
    public override void Write(
        Utf8YamlEmitter writer, Version value,
        YamlSerializerOptions options) =>
        writer.WriteStringValue(value.ToString());
}
Text
2.1.0

Unzip the example, then dotnet run.

The full program

The full program round-trips this inline input into a typed Release record:

YAML
name: app
version: 2.1.0

It also tries version: two. The converter reports invalid versions with reader.CreateException, which supplies the input line, column and path.

C#
using OneYaml;
 
var options = new YamlSerializerOptions
{
    Converters = { new VersionConverter() }
};
var release = YamlSerializer.Deserialize<Release>(
    "name: app\nversion: 2.1.0\n", options)!;
string yaml = YamlSerializer.Serialize(release, options);
Console.Write(yaml);
var copy = YamlSerializer.Deserialize<Release>(yaml, options)!;
Console.WriteLine($"Round-trip: {copy == release}");
try
{
    YamlSerializer.Deserialize<Release>(
        "name: app\nversion: two\n", options);
}
catch (YamlException error)
{
    Console.WriteLine(error.Message);
}
 
record Release(string Name, Version Version);
 
sealed class VersionConverter : YamlConverter<Version>
{
    public override Version Read(ref Utf8YamlReader reader,
        Type type, YamlSerializerOptions options)
    {
        if (reader.TokenType != YamlTokenKind.Scalar ||
            !Version.TryParse(reader.GetString(), out var version))
            throw reader.CreateException("Expected a version scalar.");
        return version;
    }
 
    public override void Write(Utf8YamlEmitter writer,
        Version value, YamlSerializerOptions options) =>
        writer.WriteStringValue(value.ToString());
}

dotnet run prints:

Text
name: app
version: 2.1.0
Round-trip: True
Expected a version scalar. (line 2, column 10, path /version)

Variations

To read just a scalar, keep the same VersionConverter class and replace the top-level statements with:

C#
using OneYaml;
 
var options = new YamlSerializerOptions
{ Converters = { new VersionConverter() } };
var version = YamlSerializer.Deserialize<Version>("3.4.5", options)!;
Console.WriteLine(version.Minor);
Text
4

The short answer

Derive from YamlConverter<T>, implement Read and Write, and add an instance to YamlSerializerOptions.Converters. Pass those options on both serialization and deserialization. In this example the wire value is one version string, while your C# code receives a System.Version. The typed record does not need to know how that scalar is represented.

Use a converter when a type has a compact representation that differs from its public properties, or when its input needs domain-specific checks. Use ordinary serialization when the YAML mapping already matches the class. A converter is not required just to rename properties or change indentation.

How do I customize YAML serialization in C#?

The Write method must emit exactly one value. WriteStringValue(value.ToString()) is enough for Version: it lets the emitter choose the quoting rather than building YAML text by hand. The full program registers one options-level converter and uses it for the Version property inside Release, not just for a top-level scalar.

Read receives the reader already positioned on the first token of that value. Leave it on the last token of the value. For a scalar those are the same token, so do not call Read() after reading the string. The serializer advances to the next property. For a mapping or sequence, the converter must instead finish on its matching end token. See the reader contract for token kinds and Skip().

The complete program checks TokenType before interpreting a scalar, then uses Version.TryParse. If either check fails it throws a binding error at the current position. This gives callers a useful location instead of an unrelated parsing exception. It also avoids treating an entire mapping as if it were a version string.

How do I deserialize YAML into a custom C# type?

Pass the options containing the converter to Deserialize<T>. The converter applies when the serializer encounters the target type, including inside a record, list or other object. The input shown above has a name string and a version scalar; Release.Version is still strongly typed after reading it.

The round-trip line compares the resulting record with the original. It demonstrates preservation of the typed values, not preservation of YAML whitespace or comments. Serialization creates YAML from the object model; use the document editing API if retaining a file’s presentation is the goal.

Converter selection has a defined precedence: an attribute on the member wins over the options list, which wins over an attribute on the type. That order applies to reflection and generated metadata. These rules and HandleNull, which controls whether Read is called for null, are documented in the serializer guide. Registering an options converter is therefore not an unconditional override of every member’s own converter.

All oneyaml C# recipes