Documentation Index
Fetch the complete documentation index at: https://mintlify.com/ProwlEngine/Prowl.Echo/llms.txt
Use this file to discover all available pages before exploring further.
By default, Prowl.Echo uses reflection to discover and serialize the fields of your types automatically. When you need finer-grained control — custom key names, computed values, conditional fields, or hand-tuned performance — you can implement the ISerializable interface to take over the process entirely. Echo checks for this interface first and short-circuits its reflection pipeline whenever it finds it.
The ISerializable Interface
namespace Prowl.Echo;
public interface ISerializable
{
void Serialize(ref EchoObject compound, SerializationContext ctx);
void Deserialize(EchoObject value, SerializationContext ctx);
}
Both methods receive a SerializationContext. You must pass this context through to any nested Serializer.Serialize / Serializer.Deserialize calls so that features like circular-reference tracking and type-mode settings propagate correctly.
Implementing ISerializable takes precedence over all reflection-based serialization. Echo will call your methods instead of scanning fields, regardless of any attributes ([SerializeField], [SerializeIgnore], etc.) that exist on the type.
Full Working Example
The example below matches exactly what is used in the Prowl.Echo test suite:
using Prowl.Echo;
public class CustomSerializableObject : ISerializable
{
public int Value = 42;
public string Text = "Custom";
public void Serialize(ref EchoObject compound, SerializationContext ctx)
{
// Write any key names you choose — they don't have to match field names.
compound.Add("customValue", new EchoObject(EchoType.Int, Value));
compound.Add("customText", new EchoObject(EchoType.String, Text));
}
public void Deserialize(EchoObject tag, SerializationContext ctx)
{
Value = tag.Get("customValue").IntValue;
Text = tag.Get("customText").StringValue;
}
}
// Usage
var original = new CustomSerializableObject { Value = 99, Text = "Hello" };
EchoObject echo = Serializer.Serialize(original);
var restored = Serializer.Deserialize<CustomSerializableObject>(echo);
Console.WriteLine(restored.Value); // 99
Console.WriteLine(restored.Text); // "Hello"
Writing to the Compound
Inside Serialize, the compound parameter is a freshly created EchoObject of type EchoType.Compound. Use compound.Add(name, echoObject) to write each value. You can construct leaf EchoObjects directly from primitive values:
compound.Add("count", new EchoObject(42)); // int
compound.Add("label", new EchoObject("my-label")); // string
compound.Add("ratio", new EchoObject(0.75f)); // float
compound.Add("active", new EchoObject(true)); // bool
Nesting: Serializing Sub-Objects
When a field holds a complex object, delegate back to Serializer.Serialize, passing through the context. This keeps reference tracking and type-mode settings intact:
public class LevelData : ISerializable
{
public string Name = "Forest";
public PlayerData Player = new();
public void Serialize(ref EchoObject compound, SerializationContext ctx)
{
compound.Add("name", new EchoObject(Name));
// Use Serializer.Serialize for sub-objects:
compound.Add("player", Serializer.Serialize(typeof(PlayerData), Player, ctx));
}
public void Deserialize(EchoObject tag, SerializationContext ctx)
{
Name = tag.Get("name").StringValue;
Player = Serializer.Deserialize<PlayerData>(tag.Get("player"), ctx)!;
}
}
Always pass typeof(TheExactFieldType) as the first argument to Serializer.Serialize inside a custom serializer. This tells Echo what the declared type is so it can decide whether a $type tag is needed for polymorphism.
Reading from the Compound
Inside Deserialize, use Get(key) (returns null if missing) or TryGet(key, out var tag) (returns a bool):
public void Deserialize(EchoObject tag, SerializationContext ctx)
{
// Direct access — throws if key is missing
Value = tag.Get("customValue").IntValue;
// Safe access with fallback
if (tag.TryGet("optionalField", out var opt))
OptionalField = opt.StringValue;
}
ISerializationCallbackReceiver: Pre/Post Hooks
If you do not want to implement ISerializable but still need to run code just before serialization or just after deserialization, implement ISerializationCallbackReceiver:
namespace Prowl.Echo;
public interface ISerializationCallbackReceiver
{
/// <summary>Called right before the Serializer serializes this object.</summary>
void OnBeforeSerialize();
/// <summary>Called right after the Serializer deserializes this object.</summary>
void OnAfterDeserialize();
}
A common use-case is keeping a serializable List in sync with a non-serializable Dictionary:
public class ItemRegistry : ISerializationCallbackReceiver
{
// Serialized backing store
public List<string> ItemKeys = new();
public List<int> ItemValues = new();
// Transient runtime structure — not serialized
[SerializeIgnore]
public Dictionary<string, int> Items = new();
public void OnBeforeSerialize()
{
ItemKeys.Clear();
ItemValues.Clear();
foreach (var (k, v) in Items)
{
ItemKeys.Add(k);
ItemValues.Add(v);
}
}
public void OnAfterDeserialize()
{
Items.Clear();
for (int i = 0; i < ItemKeys.Count; i++)
Items[ItemKeys[i]] = ItemValues[i];
}
}
Echo calls OnBeforeSerialize() at the start of AnyObjectFormat.Serialize and OnAfterDeserialize() at the end of AnyObjectFormat.Deserialize. The calls happen even when the type is also ISerializable.
ISerializable vs Source-Generated Serializers
| ISerializable | [GenerateSerializer] |
|---|
| Control | Full — write exactly what you want | Automatic — mirrors field layout |
| Effort | Manual | Minimal: one attribute on a partial type |
| Performance | Fast (no reflection at runtime) | Fastest (inlined at compile time) |
| Polymorphism / custom keys | ✅ Yes | ❌ Not without extra code |
| Reflection overhead | None | None |
| Stability | Stable | Under active development — verify generated output |
| Good for | Complex types, computed fields, legacy format compatibility | Plain data types, fixed-layout structs, hot paths |
[GenerateSerializer] (from the Prowl.Echo.SourceGenerator package) auto-generates an ISerializable implementation at compile time and requires the target type to be declared partial. Because the source generator is still actively developed, review the generated code when first adopting it. Use ISerializable manually when you need key renaming, conditional inclusion, or logic that source generation cannot express.
Complete Example: Custom Key Names and Computed Fields
using Prowl.Echo;
public class Circle : ISerializable
{
public float Radius = 1.0f;
// Computed — we don't store Area, only Radius
public float Area => MathF.PI * Radius * Radius;
public void Serialize(ref EchoObject compound, SerializationContext ctx)
{
compound.Add("r", new EchoObject(Radius));
// Storing Area is optional — here we skip it to save space
}
public void Deserialize(EchoObject tag, SerializationContext ctx)
{
Radius = tag.Get("r").FloatValue;
// Area will be correct automatically since it's computed from Radius
}
}
var c = new Circle { Radius = 5.0f };
var echo = Serializer.Serialize(c);
var c2 = Serializer.Deserialize<Circle>(echo);
Console.WriteLine(c2.Radius); // 5
Console.WriteLine(c2.Area); // ~78.54