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.
  • MessagePack, via the Serde.MsgPack nuget package
  • XML, via the Serde.Xml nuget package

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";
}

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
    • DateTimeOffset
    • 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;
}

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>.

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

    Serializes and deserializes the declaring type as the given type by going through user-defined conversions. When serializing, the declaring type is converted to the target type and serialized as that type; when deserializing, the target type is deserialized and converted back to the declaring type. User-defined conversions (implicit or explicit) must exist in the directions required by the usage: declaring type → target type for serialization, and target type → declaring type for deserialization. This is useful when a type should be represented on the wire as a primitive or another serializable type. As cannot be combined with ForType or With, and cannot be applied to enums.

    [GenerateSerde(As = typeof(string))]
    public readonly partial struct Rgb
    {
        public readonly byte R, G, B;
        public Rgb(byte r, byte g, byte b) => (R, G, B) = (r, g, b);
    
        public static explicit operator string(Rgb c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
        public static explicit operator Rgb(string s) => /* parse "#RRGGBB" */;
    }
    

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)]

    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”, “snake_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.

  • [SerdeTypeOptions(AllowDuplicateKeys = false)]

    false by default. When false, the generated implementation of IDeserialize will throw an exception if duplicate keys are encountered during deserialization. When true, duplicate keys will overwrite previous values (last value wins behavior).

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.

  • [SerdeMemberOptions(Ordinal = 0)]

    Negative (unset) by default. When set to a non-negative value, assigns the member an explicit ordinal — its stable logical identity, exposed at runtime as ISerdeInfo.GetFieldOrdinal(int). Some formats (for example, a compact array/offset-based or protobuf-style encoding) use the ordinal to encode members by position or tag rather than by name; name-based formats such as JSON ignore it.

    When any member of a type specifies an explicit ordinal, every member must specify one. Ordinals must be non-negative and unique, but they need not be contiguous: gaps (“holes”) are allowed, so an obsolete member’s ordinal can be retired without renumbering the others. Members are laid out in ascending ordinal order, and GetFieldOrdinal is guaranteed to be strictly increasing across the field positions. Like all member options, Ordinal only applies to public fields and properties; placing it on any other member is an error. It is also not allowed on enum members, which are mapped by their declared value rather than by ordinal.

    When no member declares an ordinal, ISerdeInfo.GetFieldOrdinal still returns a value, but it is just the incidental physical position (declaration order), which is not a stable identity. Use ISerdeInfo.HasExplicitFieldOrdinals to tell the two cases apart: a positional or tag-based format should only rely on ordinals for its wire layout when that flag is true.

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}");
    }
}

Types that need conversion

An empty proxy works when the external type has a parameterless constructor (or a matching primary constructor) so Serde can construct it during deserialization. Some external types don’t fit that mold — for example, a BCL type like System.Version ships without any Serde support, can’t be annotated with [GenerateSerde], and has no parameterless constructor. For those, write a non-empty proxy that mirrors the data you want on the wire, and provide explicit conversion operators in both directions:

  • ExternalType -> Proxy, used when serializing.
  • Proxy -> ExternalType, used when deserializing.

Serde generates the serialization against the proxy’s own fields and uses the operators to convert to and from the external type at the call site.

using System;
using Serde;
using Serde.Json;

namespace VersionProxySample;

// System.Version is an external BCL type: we can't add [GenerateSerde] to it, and it has no
// parameterless constructor, so an *empty* proxy won't work. Instead we write a *non-empty*
// proxy that mirrors the data we want on the wire and provide explicit conversion operators
// in both directions. Serde generates serialization against the proxy's own fields and uses
// the operators to convert to and from Version at the call site:
//   Version -> VersionProxy  (used when serializing)
//   VersionProxy -> Version  (used when deserializing)
[GenerateSerde(ForType = typeof(Version))]
partial struct VersionProxy
{
    public int Major;
    public int Minor;
    public int Build;
    public int Revision;

    public static explicit operator Version(VersionProxy p)
        => new Version(p.Major, p.Minor, p.Build, p.Revision);

    public static explicit operator VersionProxy(Version v)
        => new VersionProxy { Major = v.Major, Minor = v.Minor, Build = v.Build, Revision = v.Revision };
}

public static class Sample
{
    public static void Run()
    {
        var version = new Version(1, 2, 3, 4);
        Console.WriteLine($"Original version: {version}");

        // Serialize the Version, going through the Version -> VersionProxy conversion.
        // produces: {"major":1,"minor":2,"build":3,"revision":4}
        var json = JsonSerializer.Serialize<Version, VersionProxy>(version);
        Console.WriteLine($"Serialized version: {json}");

        // Deserialize, going through the VersionProxy -> Version conversion.
        var deVersion = JsonSerializer.Deserialize<Version, VersionProxy>(json);
        Utils.AssertEq(version, deVersion);
        Console.WriteLine($"Deserialized version: {deVersion}");
    }
}

Serializing a Version then produces JSON from the proxy’s fields:

{"major":1,"minor":2,"build":3,"revision":4}

Note that the proxy only needs the conversion operators for the directions you actually generate. A serialize-only proxy ([GenerateSerialize(ForType = ...)]) needs only ExternalType -> Proxy, and a deserialize-only proxy ([GenerateDeserialize(ForType = ...)]) needs only Proxy -> ExternalType.

Customization

Serde gives you several ways to control how a type is serialized, ranging from small tweaks to a completely hand-written implementation. Use this table to pick the right one:

SituationUseMore info
You own the type and the default member-by-member shape is fine[GenerateSerde]Using the source generator
You own the type but want to rename members, skip nulls, etc.[SerdeTypeOptions] / [SerdeMemberOptions]Configuration options
You own the type and want it on the wire as another serializable type (e.g. a primitive), via conversion operators[GenerateSerde(As = typeof(...))]Configuration options
You own the type and need full manual control of the wire format[GenerateSerde(With = typeof(...))]Below
You don’t own the type, but it has a usable constructor and the default shape is fineempty [GenerateSerde(ForType = typeof(...))] proxyExternal types
You don’t own the type and it needs a custom shape or has no usable constructornon-empty [GenerateSerde(ForType = typeof(...))] proxy with conversion operatorsExternal types

Note: As and the non-empty ForType proxy are the same underlying mechanism — user-defined conversion operators to a “wire” type. The only difference is whether you own the declaring type (use As) or not (use a ForType proxy).

Full manual control with With

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 { get; } = StringProxy.SerdeInfo.WithName("Color");

    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.

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
{
    internal static class SerTypeInfo<T, TProvider>
        where TProvider : ISerializeProvider<T>
    {
        public static readonly ISerdeInfo Instance = Serde.SerdeInfo.MakeEnumerable("EqArray", TProvider.Instance.SerdeInfo);
    }

    internal static class DeTypeInfo<T, TProvider>
        where TProvider : IDeserializeProvider<T>
    {
        public static readonly ISerdeInfo Instance = Serde.SerdeInfo.MakeEnumerable("EqArray", TProvider.Instance.SerdeInfo);
    }

    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 => SerTypeInfo<T, TProvider>.Instance;

        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 => DeTypeInfo<T, TProvider>.Instance;
        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.");
        }
    }
}