View on GitHub

Overview

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

Design

Unlike many other serialization libraries, serde-dn 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-dn instead uses two interfaces, ISerialize, and IDeserialize to allow each type to control how it is serialized. Serde-dn 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 safety by ensuring all requested types support serialization.

Formats

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

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

Getting started

This full sample is also available in the Github repo.

Start by adding the serde NuGet package:

dotnet add package serde

You can now use the [GenerateSerialize] and [GenerateDeserialize] attributes to automatically implement serialization and deserialization for your own types. Don't forget to mark them partial!

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

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

Using the source generator

Serde-dn is type-safe, meaning that it requires every serializing type to implement ISerialize or IDeserialize and always uses an interface implementation for controlling serialization.

For types you control, you could implement those interfaces manually, but most cases can be solved by using the built-in source generator. Two changes are required to use the source generator:

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

For example,

class SampleClass
{
  ...
}

would become

[GenerateSerialize]
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. Be a serde-dn built-in type, like int or string.
  2. If the type is in the current assembly, implement I(De)Serialize.
  3. Be defined in an external assembly, where the source generator will automatically wrap the type (see generated wrappers).
  4. Define a wrapper type in the current assembly named <TypeName>Wrap in the Serde namespace.
  5. Specify a custom wrapper.

The types of the fields and properties must also implement ISerialize. Many of the most common types, like int, string, or even List<string>, have built-in support from serde-dn and already have ISerialize implementations. If any of those public fields or properties have a type that you control, they will also need to implement ISerialize or IDeserialize. This is also true for nested references, e.g. List<MyOtherType>.

If you don't control any of the types you need to serialize or deserialize (i.e., they are defined in another assembly that you can't modify) you'll need to use a wrapper to implement the interface. See wrappers for more info.

Additional IDeserialize requirements

Serde-dn is type safe and always uses reflection-free C# code. Thus, to implement IDeserialize the type must have an accessible constructor and accessible members.

By default, serde-dn requires

  1. A parameterless constructor
  2. All fields and properties are writable during creation.

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

Wrappers

Wrappers are an essential part of the serde-dn design. Many types aren't under user control and won't be able to implement the ISerialize or IDeserialize interfaces directly. Instead, simple wrapper structs are used to proxy the target type.

Often, wrappers will be created for you as necessary. If you are serializing a type you define, the source generator will automatically create wrappers for nested external types. However, wrappers will never be automatically created for types in your source code. If you control the type, it must implement the required interfaces directly.

Creating a wrapper type

By convention, wrappers are named <OriginalTypeName>Wrap and are always placed in the Serde namespace. Since wrappers are normal C#, you could choose to implement the ISerialize or IDeserialize logic yourself, just as you would for a custom implementation on your own type. However, the serde-dn source generator also has support for generating implementations for wrappers.

To use implement via source generator do the following:

  1. Create a wrapper type (often a struct) named <OriginalTypeName>Wrap in the Serde namespace.
  2. Make it partial.
  3. Add a field or property to store the wrapped instance.
  4. Add the [GenerateWrapper] attribute and pass it the name of your field or property as a string (maybe using nameof)

The "records" feature is particularly useful for this:

using Serde;

// Type we can't modify
class ExternalClass { ... }

namespace Serde
{
    [GenerateWrapper(nameof(Value))]
    partial readonly record struct ExternalClassWrap(ExternalClass Value);
}

By default, this will implement both ISerialize and IDeserialize in the wrapper.

Configuration options

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

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

Type options

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.

Handling generic types

For most types, adding Serde.NET support is as easy as implementing the ISerialize and IDeserialize interfaces, which the source generator can do for you.

Generic types are usually not so easy, because of a specific restriction in the .NET type system.

Let's say you have a simple custom generic list type, like MyList<T>. Serde already provides built-in support for automatically wrapping Lists using prewritten wrappers, but you might have a customization you want to provide. In that case you might try implementing ISerialize and IDeserialize yourself, but run into a problem -- for MyList<T> to be serializable, all its elements (T) must be serializable.

The natural inclination would be to add a constraint to the MyList<T> definition: MyList<T> where T : ISerialize<T>, IDeserialize<T>. Unfortunately, that won't work. First, it would create a requirement that you could only put serializable elements into the MyList<T> type. However, that's not the contract you want to provide. You want to support serialization in the case that all types are serializable, but you don't want to require that all types be serializable. Second, even the primitive types, like int and string, don't implement ISerialize<T> or IDeserialize<T> directly -- they use wrappers.

So what's the solution? Using a wrapper type instead. Rather than implement serialization on MyList<T> itself, define a wrapper type for Serde, then point to that wrapper type from the MyList<T> definition. The information at Wrappers is very useful as background.

One important point is that generic wrappers are slightly different from regular wrappers. To be more flexible they provide serialization and deserialization separately, and therefore are implemented using a different pattern. They start with a static class at the top level, and feature a SerializeImpl and DeserializeImpl nested beneath. For MyList<T> this would look like,

public static class MyListSerdeWrap
{
    public readonly record struct SerializeImpl<T, TWrap>(MyList<T> Value) : ISerialize, ISerialize<MyList<T>>
        where TWrap : struct, ISerialize, ISerialize<T>, ISerializeWrap<T, TWrap>
    {
        ...
    }
    public readonly record struct DeserializeImpl<T, TWrap> : IDeserialize<MyList<T>>
        where TWrap : IDeserialize<T>
    {
        ...
    }
}

Note that each nested class takes at least two type parameters. The first type parameter is for the type parameter of the original type. The second is for the wrapper that might be needed for previous type parameter. The rule is that for n type parameters on the original type, you'll need 2n type paramaeters for the wrapper type.

The implementation of the wrapper is otherwise standard. For collections, you can reference the prewritten List and Dictionary wrappers for implementation tips.