Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Overview

Serde.NET is a .NET port of the popular serde.rs Rust serialization/deserialization library.

Design

Unlike many other serialization libraries, Serde.NET is multi-format, high performance, source-generated, and fully compatible with app trimming and Native AOT.

Most other .NET serialization libraries rely on run-time reflection to serialize types. Serde instead uses two interfaces, ISerialize, and IDeserialize to allow each type to control how it is serialized. Serde.NET uses source generation to implement these interfaces, so you almost never need to implement them manually. Source generation and interfaces avoids all run-time reflection and its overhead, and provides type and memory safety by ensuring all requested types support serialization. Serde.NET also does not have any unsafe code.

Formats

Serde.NET is a multi-format serialization library, with built-in support for JSON.

Serde.NET is multi-format because it separates how a type serializes itself from the knowledge of the data format. Rather than have individual interfaces for each format, serde uses ISerialize and IDeserialize for all formats. Adding support for new formats comes from implementing two different interfaces: ISerializer and IDeserializer. Since each interface pair is separate, new data format support can be added via NuGet packages.

Supported formats:

  • JSON, the flagship format. Bundled with Serde.NET.

Getting started

This full sample is also available in the Github repo.

Steps to get started:

  1. Add the serde NuGet package:
$ dotnet add package serde
  1. Add the partial modifier to the type you want to serialize/deserialize.
  2. Add one of the [GenerateSerde], [GenerateSerialize], or [GenerateDeserialize] attributes.
using Serde;
using Serde.Json;

string output = JsonSerializer.Serialize(new SampleClass());

// prints: {"X":3,"Y":"sample"}
Console.WriteLine(output);

var deserialized = JsonSerializer.Deserialize<SampleClass>(output);

// prints SampleClass { X = 3, Y = sample }
Console.WriteLine(deserialized);

[GenerateSerde]
partial record SampleClass
{
    // automatically includes public properties and fields
    public int X { get; init; } = 3;
    public string Y = "sample";
}

Using the source generator

Serialization in serde is driven by implementions of the ISerde<T> interface through a "serde object". Serde ships with a source generator that can automatically implement these interfaces for any type.

To use the source generator:

  1. Make the type partial.
  2. Add the GenerateSerde, GenerateSerialize, or GenerateDeserialize attribute

For example,

class SampleClass
{
  ...
}

would become

[GenerateSerde]
partial class SampleClass
{
  ...
}

By default, the source generator will include all the public properties and public fields. The field or property types must either,

  1. Directly implement the serde interfaces using [GenerateSerde]
  2. Be a serde-dn built-in type, like int or string.
  3. Specify a proxy using [SerdeMemberOptions(Proxy = typeof(Proxy))]

If you don't control any of the types you need to serialize or deserialize (e.g., they are defined in another assembly that you can't modify) you'll need to specify a proxy. See External types for more info.

Additional IDeserialize requirements

Deserialization needs a way create and initialize a given type. There are two recognized patterns:

  1. A parameterless constructor, where all the fields/properties are writable.
  2. A primary constructor, where all the fields/properties are either writable, or the same name as a one of the constructor parameters.

If this isn't possible, the constructor used can be configured through the SerdeTypeOptions attribute.

Configuration options

Serde-dn provides a variety of options that can be specified via attributes to configure the generated serialization and deserialization implementations.

Direct Generate options

The GenerateSerde, GenerateSerialize, and GenerateDeserialize attributes have a few options to select a custom implementation.

  • [GenerateSerde(ForType = typeof(...))]

    Used to create proxy types. See External types for more info.

  • [GenerateSerde(With = typeof(...))]

    Used to override the generation of the serde object with the specified custom serde object. The target type needs to implement ISerde<T>.

Type options

To apply options to an entire type and all its members, use [SerdeTypeOptions]. To apply options to one member in particular, use [SerdeMemberOptions].

Note that these options only apply to the target type, not the type of nested members (including ones which have wrappers auto-generated). To provide options for member types the attribute will also need to be applied to them (or their wrapper).

  • [SerdeTypeOptions(MemberFormat = ...)]

    MemberFormat.CamelCase by default. Renames all the fields or properties of the generated implementation according to the given format. The possible formats are "camelCase", "PascalCase", "kebab-case", and "none". "none" means that the members should not be renamed.

  • [SerdeTypeOptions(SerializeNull = false)]

    false by default. When false, serialization for members will be skipped if the value is null. When true, null will be serialized like all other values.

  • [SerdeTypeOptions(DenyUnknownMembers = false)]

    false by default. When false, the generated implementation of IDeserialize will skip over any members in the source that aren't defined on the type. When true, an exception will be thrown if there are any unrecognized members in the source.

Member options

  • [SerdeMemberOptions(ThrowIfMissing = false)]

    false by default. When true, throws an exception if the target field is not present when deserializing. This is the default behavior for fields of non-nullable types, while the default behavior for nullable types is to set the field to null.

  • [SerdeMemberOptions(SerializeNull = false)]

    false by default. When false, serialization for this member will be skipped if the value is null. When true, null will be serialized like all other values.

  • [SerdeMemberOptions(Rename = "name")]

    null by default. When not null, renames the current member to the given argument.

Data model

Serde works by taking .NET code, transforming it into the Serde data model, and then translating it into the various output formats. The Serde data model is a subset of the .NET type system, focused on only the pieces that can be translated to serialization output formats.

In code, the Serde data model is split into two sides: .NET types and output formats. .NET types implement the ISerialize<T>, IDeserialialize<T>, and ISerdeInfo interfaces. Output formats implement the ISerializer and IDeserializer interfaces.

The Serde data model is as follows:

  • Primitive types
    • bool
    • I8, I16, I32, and I64 (sbyte, short, int, and long)
    • U8, U16, U32, and U64 (byte, ushort, uint, and ulong)
    • F32 and F64 (float and double)
    • decimal
    • string
    • DateTime
  • Contiguous array of bytes
    • Serialization: ReadOnlyMemory<byte>
    • Deserialization: IBufferWriter<byte>
  • Custom types
    • Structs, classes, and records.
  • Lists
    • A sequence of elements, like an array or a list. The length does not need to be known ahead-of-time.
  • Dictionaries
    • A variable-size key-value pair. The length does not need to be known ahead-of-time.
  • Nullable types
    • Both nullable reference types and nullable value types are supported. All reference types not marked as nullable (? suffix) are assumed to be non-null.
  • Enums
  • Unions
    • Sometimes referred to as "polymorphic types." C# does not yet have built-in support for discriminated unions, but the source generator will match a particular pattern anyway. To write a union type, use an abstract record type, with nested records that inherit from the parent record. The parent record must only have private constructors. For example,
[StaticCs.Closed] // Optional library for annotating union types
[GenerateSerde]
abstract partial record UnionBase
{
    private UnionBase() { }

    public partial record DerivedA(int A) : UnionBase;
    public partial record DerivedB(string B) : UnionBase;
}

Customization

For complete control, you can disable source-generated implementation of the ISerde<T>/ISerialize<T>/IDeserialize<T> interfaces entirely and implement it yourself.

To specify the custom object, use [GenerateSerde(With = typeof(CustomObject))]. The custom type can be a private nested class, if desired.

Here is a simple example where Colors are converted to strings:


// Define a custom Color type
using System;
using Serde;
using Serde.Json;

namespace CustomSerdeSample;

// Attach a custom serializer to the Color type
[GenerateSerde(With = typeof(ColorSerdeObj))]
partial record Color(int R, int G, int B);

// Create a serde object for the Color type that serializes as a hex string
class ColorSerdeObj : ISerde<Color>
{
    // Color is serialized as a hex string, so it looks just like a string in the serialized form.
    public ISerdeInfo SerdeInfo => StringProxy.SerdeInfo;

    public void Serialize(Color color, ISerializer serializer)
    {
        var hex = $"#{color.R:X2}{color.G:X2}{color.B:X2}";
        serializer.WriteString(hex);
    }

    public Color Deserialize(IDeserializer deserializer)
    {
        var hex = deserializer.ReadString();
        if (hex.Length != 7 || hex[0] != '#')
            throw new FormatException("Invalid hex color format");

        return new Color(
            Convert.ToInt32(hex[1..3], 16),
            Convert.ToInt32(hex[3..5], 16),
            Convert.ToInt32(hex[5..7], 16));
    }
}

static class CustomSerializationSample
{
    public static void Run()
    {
        var color = new Color(255, 0, 0);
        Console.WriteLine($"Original color: {color}");

        // Serialize the color to a JSON string
        var json = JsonSerializer.Serialize(color);
        Console.WriteLine($"Serialized color: {json}");

        // Deserialize the JSON string back to a Color object
        var deserializedColor = JsonSerializer.Deserialize<Color>(json);
        Utils.AssertEq(color, deserializedColor);
        Console.WriteLine($"Deserialized color: {deserializedColor}");
    }
}

In the above, the custom implementation also implements the required interface ISerdeInfo. The implementation of all these interfaces must match.

External types

Sometimes you need to serialize or deserialize a type you don't control or can't modify. In these cases, Serde uses a "proxy type" to stand-in for the external type. To create a proxy type, simply create a new class and add the attribute [GenerateSerde(ForType = typeof(ExternalType)]. Serde will automatically use the public properties and fields on the external type if the proxy type is empty. Here's a simple example that assumes there's an external Response record that you can't modify.


using System;
using Serde;
using Serde.Json;

namespace ExternalTypesSample;

// Pretend that Response is an external type that we can't modify directly
public record Response(string ResponseType, string Body);

// Proxy for the Response type.
// We use the [GenerateSerde] attribute with the `ForType` parameter to control
// generation for the proxy type. Since the ResponseProxy type is empty, Serde
// will assume that we want to automatically use all the public properties and
// fields of the Response type, with no further customization.
[GenerateSerde(ForType = typeof(Response))]
partial class ResponseProxy;

public class Sample
{
    public static void Run()
    {
        var resp = new Response(ResponseType: "success", Body: "hello, world");
        Console.WriteLine($"Original version: {resp}");

        // Serialize the Response to a JSON string
        // In addition to the Response type, we also have to pass in the proxy type
        var json = JsonSerializer.Serialize<Response, ResponseProxy>(resp);
        Console.WriteLine($"Serialized version: {json}");

        // Deserialize the JSON string back to a Response object
        var deResp = JsonSerializer.Deserialize<Response, ResponseProxy>(json);
        Utils.AssertEq(resp, deResp);
        Console.WriteLine($"Deserialized version: {deResp}");
    }
}

Handling generic types

Generic types are more complicated because serialize and deserialize must be separated, in case one of the nested types only implements one of the two operations.

The pattern is to separate into two separate classes nested underneath a generic type. An example of a custom collection type is as follows:


using System;
using System.Collections.Immutable;
using System.Linq;
using Serde;
using Serde.Json;

namespace EqArraySample;

[SerdeTypeOptions(Proxy = typeof(EqArrayProxy))]
public readonly struct EqArray<T>(ImmutableArray<T> value)
{
    public ImmutableArray<T> Array => value;
}

public static class EqArrayProxy
{
    private static readonly ISerdeInfo s_typeInfo = Serde.SerdeInfo.MakeEnumerable("EqArray");
    public sealed class Ser<T, TProvider>
        : ISerializeProvider<EqArray<T>>, ISerialize<EqArray<T>>
        where TProvider : ISerializeProvider<T>
    {
        public static readonly Ser<T, TProvider> Instance = new();
        static ISerialize<EqArray<T>> ISerializeProvider<EqArray<T>>.Instance => Instance;

        public ISerdeInfo SerdeInfo => s_typeInfo;

        void ISerialize<EqArray<T>>.Serialize(EqArray<T> value, ISerializer serializer)
        {
            ImmutableArrayProxy.Ser<T, TProvider>.Instance.Serialize(
                value.Array,
                serializer
            );
        }
    }

    public sealed class De<T, TProvider> : IDeserializeProvider<EqArray<T>>, IDeserialize<EqArray<T>>
        where TProvider : IDeserializeProvider<T>
    {
        public static readonly De<T, TProvider> Instance = new();
        static IDeserialize<EqArray<T>> IDeserializeProvider<EqArray<T>>.Instance => Instance;

        public ISerdeInfo SerdeInfo => s_typeInfo;
        EqArray<T> IDeserialize<EqArray<T>>.Deserialize(IDeserializer deserializer)
        {
            return new(ImmutableArrayProxy.De<T, TProvider>.Instance.Deserialize(deserializer));
        }
    }
}

public class GenericTypeSample
{
    public static void Run()
    {
        var eq = new EqArray<int>([ 1, 2, 3, 4 ]);

        // Serialize the version to a JSON string.
        // Here we pass the proxy parameter explicitly, but if EqArray is a nested field, the
        // source generator will pass it automatically.
        var json = JsonSerializer.Serialize<EqArray<int>, EqArrayProxy.Ser<int, I32Proxy>>(eq);
        Console.WriteLine($"Serialized version: {json}");

        var deEq = JsonSerializer.Deserialize<EqArray<int>, EqArrayProxy.De<int, I32Proxy>>(json);
        if (!eq.Array.SequenceEqual(deEq.Array))
        {
            throw new InvalidOperationException("Deserialized version does not match the original.");
        }
    }
}