SkillAgentSearch skills...

Serialization

Easy to use serializable models with AOT compilation support and System.Text.Json compatibility.

Install / Use

/learn @chickensoft-games/Serialization
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

💾 Serialization

[![Chickensoft Badge][chickensoft-badge]][chickensoft-website] [![Discord][discord-badge]][discord] ![line coverage][line-coverage] ![branch coverage][branch-coverage]

System.Text.Json-compatible source generator with automatic support for derived types and polymorphic serialization.


<p align="center"> <img alt="Chickensoft.Serialization" src="Chickensoft.Serialization/icon.png" width="200"> </p>

📕 Background

  • ✅ Support 0-configuration polymorphic serialization in AOT builds.
  • ✅ Support versioning and upgrading outdated models.
  • ✅ Allow types to access and customize their own JSON representation via serialization/deserialization hooks.
  • ✅ Support abstract types.
  • ✅ Support nested types.

The Chickensoft Serialization system allows you to easily declare serializable types that will work when compiled for ahead-of-time (AOT) environments, like iOS. It can be easily used alongside the System.Text.Json source generators for more complex usage scenarios.

  [Meta, Id("book")]
  public partial record Book {
    [Save("title")]
    public required string Title { get; set; }

    [Save("author")]
    public required string Author { get; set; }

    [Save("related_books")]
    public Dictionary<string, List<HashSet<string>>>? RelatedBooks { get; set; }
  }

  [Meta, Id("bookcase")]
  public partial record Bookcase {
    [Save("books")]
    public required List<Book> Books { get; set; }
  }

Example model:

var book = new Book {
  Title = "The Book",
  Author = "The Author",
  RelatedBooks = new Dictionary<string, List<HashSet<string>>> {
    ["Title A"] = [["Author A", "Author B"]],
  }
};

Serialized JSON:

{
  "$type": "book",
  "$v": 1,
  "author": "The Author",
  "related_books": {
    "Title A": [
      [
        "Author A",
        "Author B"
      ]
    ]
  },
  "title": "The Book"
}

🥳 Overview

The serialization system is designed to be simple and easy to use. Under the hood, it leverages the Chickensoft [Introspection] generator to avoid using reflection that isn't supported when targeting AOT builds. The Chickensoft Introspection generator is also decently fast, since it only uses syntax nodes instead of relying on analyzer symbol data, which can be very slow.

The serialization system uses the same, somewhat obscure (but public) API's that the generated output of the System.Text.Json source generators use to define metadata about serializable types.

Annoyingly, System.Text.Json requires you to tag derived types on the generation context, which makes refactoring type hierarchies painful and prone to human error if you forget to update. The Chickensoft serialization system automatically handles derived types so you don't have to think about polymorphic serialization and maintain a list of types anywhere.

✋ Intentional Limitations

  • ❌ Generic types are not supported.
  • ❌ Models must have parameterless constructors.
  • ❌ Serializable types must be partial.
  • ❌ Only HashSet<T>, List<T>, and Dictionary<TKey, TValue> collections are supported.
  • ❌ The root type passed into JsonSerializer.Serialize must be an object, not a collection. Collections are only supported inside of other objects.

The Chickensoft serializer has strong opinions about how JSON serialization should be done. It's primarily intended to simplify the process of defining models for game save files, but you can use it any C# project which supports C# >= 11.

[!TIP] [Keep your JSON models simple][json-complexity].

If you must do something fancy, the serialization system integrates seamlessly with System.Text.Json and generated serializer contexts. The Chickensoft serialization system is essentially just a special IJsonTypeInfoResolver and JsonConverter<object> working together.

🥚 Installation

You'll need the serialization package, as well as the [Introspection] package and its source generator.

Make sure you get the latest versions of the packages here on nuget: [Chickensoft.Introspection], [Chickensoft.Introspection.Generator], [Chickensoft.Serialization].

<PackageReference Include="Chickensoft.Serialization" Version=... />
<PackageReference Include="Chickensoft.Introspection" Version=... />
<PackageReference Include="Chickensoft.Introspection.Generator" Version=... PrivateAssets="all" OutputItemType="analyzer" />

[!WARNING] We strongly recommend treating warning CS9057 as an error to catch possible compiler-mismatch issues with the Introspection generator. (See the [Introspection] README for more details.) To do so, add a WarningsAsErrors line to your .csproj file's PropertyGroup:

<PropertyGroup>
  <TargetFramework>net8.0</TargetFramework>
  ...
  <!-- Catch compiler-mismatch issues with the Introspection generator -->
  <WarningsAsErrors>CS9057</WarningsAsErrors>
  ...
</PropertyGroup>

[!WARNING] Don't forget the PrivateAssets="all" OutputItemType="analyzer" when including a source generator package in your project.

💾 Serializable Types

𝚫 Defining a Serializable Type

To declare a serializable model, add the [Meta] and [Id] attributes to a type.

When your project is built, the Introspection generator will produce a registry of all the types visible from the global scope of your project, as well as varying levels of metadata about the types based on whether they are instantiable, introspective, versioned, and/or identifiable. For more information, check out the [Introspection] generator readme.

using Chickensoft.Introspection;

[Meta, Id("model")]
public partial class Model { }

[!CAUTION] Note that a model's id needs to be globally unique across all serializable types in every assembly that your project uses. The id is used as the model's [type discriminator] for polymorphic deserialization.

📼 Serializing and Deserializing

The serialization system leverages the serialization infrastructure provided by System.Text.Json. To use it, simply create a JsonSerializerOptions instance with a SerializableTypeResolver and SerializableTypeConverter.

var options = new JsonSerializerOptions {
  WriteIndented = true,
  TypeInfoResolver = new SerializableTypeResolver(),
  Converters = { new SerializableTypeConverter() }
};

var model = new Model();

var json = JsonSerializer.Serialize(model, options);

var modelAgain = JsonSerializer.Deserialize<Model>(json, options);

☑️ Defining Serializable Properties

To define a serializable property, add the [Save] attribute to the property, specifying its json name.

[Meta, Id("model")]
public partial class Model {
  [Save("name")]
  public required string Name { get; init; } // required allows it to be non-nullable

  [Save("description")]
  public string? Description { get; init; } // not required, should be nullable
}

[!TIP] By default, properties are not serialized. This omit-by-default policy enables you to inherit functionality from other types while adding support for serialization in scenarios where you do not fully control the type hierarchy.

Fields are never serialized.

For best results, mark non-nullable properties as [required] and use init properties for models.

🪆 Polymorphism

Abstract types are supported. Serializable types inherit serializable properties from base types.

[!TIP] Instead of placing an [Id] on the abstract type, place it on each derived type.

[Meta]
public abstract partial class Person {
  [Save("name")]
  public required string Name { get; init; }
}

[Meta, Id("doctor")]
public partial class Doctor : Person {
  [Save("specialty")]
  public required string Specialty { get; init; }
}

[Meta, Id("lawyer")]
public partial class Lawyer : Person {
  [Save("cases_won")]
  public required int CasesWon { get; init; }
}

⏳ Versioning

The serialization system provides support for versioning models when requirements inevitably change.

[!CAUTION] Versioning does not work for value types.

There are some situations where adding non-required fields to an existing model is not possible, such as when the type of a field changes or you want to introduce a required property.

Fortunately, the serialization system allows you to declare multiple versions of the same model. Version numbers are simple integer values.

👯‍♀️ Defining Multiple Versions of a Type

The following LogEntry model extends a non-serializable type SystemLogEntry. We will introduce a change to the Type property, making it a LogType enum instead of a string.

[Meta, Id("log_entry")]
public abstract partial class LogEntry : SystemLogEntry {
  [Save("text")]
  public required string Text { get; init; }

  [Save("type")]
  public required string Type { get; init; }
}

To introduce a new version, you first need to create a common base type for all the versions.

We first rename the current LogEntry to LogEntry1 and introduce a new abstract type which extends SystemLogEntry — a type that we don't have direct control over. Then, we simply update the LogEntry1 model to inherit from the abstract LogEntry.

By default, instantiable introspective types have a default version of 1. We will go ahead and add the [Version] attribute anyways to make it more clear.

// We make an abstract type that the specific versions extend.
[Meta, Id("log_entry")]
public abstract partial class LogEntry : SystemLogEntry { }

// Used to be LogEntry, but is now LogEntry1.
[Meta, Version(1)]
public partial class LogEntry1 : LogEntry {
  [Save("text")]
  public required string Text { get; init; }

  [Save("type")]
  public required string Type { get; init; }
}

[!TIP] Note that the [Id] attribute is only on the abstract base log entry type.

Finally, we can introduce a new version:

public enum LogType {
  Info,
  Warning,
  Error
}

[Meta, Version(2)]
public partial class LogEntry2 

Related Skills

View on GitHub
GitHub Stars70
CategoryCustomer
Updated9d ago
Forks6

Languages

C#

Security Score

100/100

Audited on Mar 17, 2026

No findings