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.
When a field is declared as a base type or interface but holds a value of a more derived concrete type at runtime, Echo must record which concrete type was actually stored so it can reconstruct the correct object on deserialization. This mechanism is called type preservation, and it is controlled by the TypeMode enum passed to SerializationContext.
Consider a field declared as Animal that holds a Dog at runtime:
public class Animal { public string Species = "Unknown"; }
public class Dog : Animal { public string Breed = "Husky"; }
public class Kennel
{
public Animal Occupant = new Dog { Species = "Canine", Breed = "Labrador" };
}
Without a $type tag, Echo would deserialize Occupant as a plain Animal, discarding the Breed field and losing the concrete type entirely. With a $type tag, it knows to create a Dog instead.
TypeMode Enum
public enum TypeMode
{
/// <summary>Always include type information.</summary>
Aggressive,
/// <summary>
/// Include type information only when the actual type differs from the declared type.
/// This is the default.
/// </summary>
Auto,
/// <summary>
/// Never include type information. Deserialization may fail if the type is
/// not the expected type.
/// </summary>
None
}
Pass a TypeMode directly or via a SerializationContext:
// Default (Auto)
EchoObject echo = Serializer.Serialize(value);
// Explicit mode
EchoObject echo = Serializer.Serialize(value, TypeMode.Aggressive);
// Via context
var ctx = new SerializationContext(TypeMode.Aggressive);
EchoObject echo = Serializer.Serialize(value, ctx);
TypeMode.Auto (Default)
In Auto mode, Echo embeds a $type tag only when the actual runtime type differs from the declared (target) type. When they match, no extra bytes are written.
var ctx = new SerializationContext(TypeMode.Auto);
var simpleObj = new SimpleObject();
// Declared and actual types match — no $type emitted
EchoObject withoutType = Serializer.Serialize(typeof(SimpleObject), simpleObj, ctx);
Console.WriteLine(withoutType.TryGet("$type", out _)); // False
// Declared type is object, actual is SimpleObject — $type emitted
EchoObject withType = Serializer.Serialize(typeof(object), simpleObj, ctx);
Console.WriteLine(withType.TryGet("$type", out _)); // True
When you call Serializer.Serialize(object? value) (no explicit target type), Echo always wraps the root object with type information in Auto mode so that deserialization can reconstruct the correct type without the caller needing to know it upfront.
TypeMode.Aggressive
Aggressive mode embeds a $type tag on every serialized object, even when the declared and actual types are identical. Use this when you need the most robust deserialization guarantees — for example, in network protocols where the receiver may not know the expected type statically.
var ctx = new SerializationContext(TypeMode.Aggressive);
EchoObject echo = Serializer.Serialize(typeof(SimpleObject), new SimpleObject(), ctx);
// $type is always present
Console.WriteLine(echo.TryGet("$type", out _)); // True
TypeMode.None
None mode never writes $type. Useful for compact formats where types are always known from context. Deserialization will fail or produce incorrect results if the actual type differs from the declared type.
var ctx = new SerializationContext(TypeMode.None);
EchoObject echo = Serializer.Serialize(typeof(object), new ComplexObject(), ctx);
// $type is absent — deserializer must know the type
Console.WriteLine(echo.TryGet("$type", out _)); // False
Abstract Base Classes
Declare a field as an abstract type, serialize a concrete subclass, and Echo will restore the concrete type:
public abstract class Shape
{
public string Color = "Red";
}
public class Circle : Shape
{
public float Radius = 1.0f;
}
public class Rectangle : Shape
{
public float Width = 2.0f;
public float Height = 3.0f;
}
Shape original = new Circle { Color = "Blue", Radius = 5.0f };
EchoObject echo = Serializer.Serialize(original); // root always gets type in Auto mode
Shape? restored = Serializer.Deserialize<Shape>(echo);
Console.WriteLine(restored is Circle); // True
Console.WriteLine(((Circle)restored).Radius); // 5.0
Interface Fields
Fields typed as interfaces work the same way. As long as the concrete type is serializable, Echo records its name and reconstructs the correct implementation:
public interface IAnimal { }
public class Dog : IAnimal
{
public string Species = "Canine";
public string Breed = "Golden Retriever";
}
var original = new Dog { Species = "Canine", Breed = "Golden Retriever" };
// Serialize with Aggressive mode so $type is always written
var ctx = new SerializationContext(TypeMode.Aggressive);
EchoObject echo = Serializer.Serialize(original, ctx);
// Deserialize as the interface type
IAnimal? animal = Serializer.Deserialize<IAnimal>(echo);
Console.WriteLine(animal is Dog); // True
Console.WriteLine(((Dog)animal).Breed); // "Golden Retriever"
Polymorphic List — Mixed Concrete Types
The most common polymorphism scenario: a List<BaseType> that contains several concrete subtypes.
public abstract class MonoBehaviour
{
public string Name = "";
}
public class Component : MonoBehaviour
{
public int Value;
}
public class GameObject
{
public string Name = "";
public List<MonoBehaviour> Components = new();
}
var go = new GameObject { Name = "Player" };
go.Components.Add(new Component { Name = "Health", Value = 100 });
go.Components.Add(new Component { Name = "Speed", Value = 10 });
EchoObject echo = Serializer.Serialize(go);
GameObject? restored = Serializer.Deserialize<GameObject>(echo);
Console.WriteLine(restored.Components[0].GetType().Name); // "Component"
Console.WriteLine(((Component)restored.Components[0]).Value); // 100
Each element whose concrete type differs from the list’s declared element type (MonoBehaviour) receives a $type tag automatically in Auto mode.
How $type Is Written into the EchoObject
For complex (non-primitive) types, Echo adds $type as a string key directly inside the compound:
Compound {
"$type": String("Prowl.Echo.Test.Dog, Prowl.Echo.Tests")
"Species": String("Canine")
"Breed": String("Golden Retriever")
}
For primitive and simple value types (int, float, string, Guid, enum, etc.) that need a type tag, Echo uses a compact two-key wrapper:
Compound {
"$t": String("i") // compact type alias
"$v": Int(42)
}
Verifying Type Safety Before Deserialization
In networked or untrusted-input scenarios, deserializing an unknown type can be a security risk. Use GetStoredType() to inspect the type embedded in a serialized compound before calling Deserialize:
EchoObject received = ReceivePacketFromNetwork();
Type? storedType = received.GetStoredType();
if (storedType == null || !typeof(IPacket).IsAssignableFrom(storedType))
{
// Reject the packet — it does not implement IPacket
throw new SecurityException("Received packet with unexpected type.");
}
IPacket packet = (IPacket)Serializer.Deserialize(received, typeof(IPacket))!;
GetStoredType() reads the $type string tag and resolves it against the loaded assemblies without constructing any object. It returns null if no type tag is present.
Always validate GetStoredType() before deserializing data that arrived from an untrusted source. Without this check, a malicious sender could supply a type that triggers unintended code paths or resource allocation during construction.
Nested Objects and Selective Type Embedding
In Auto mode, type tags are only added where needed. A field whose declared and actual types match will not receive a $type, even if its parent compound does:
public class Container
{
public SimpleObject ExactMatch = new SimpleObject(); // declared == actual
public SimpleObject DerivedField = new SimpleInheritedObject(); // declared != actual
}
var obj = new Container();
var ctx = new SerializationContext(TypeMode.Auto);
EchoObject echo = Serializer.Serialize(obj, ctx);
// ExactMatch: no $type
Console.WriteLine(echo.Get("ExactMatch").TryGet("$type", out _)); // False
// DerivedField: $type is present because the concrete type differs
Console.WriteLine(echo.Get("DerivedField").TryGet("$type", out _)); // True
Custom ISerializationFormat implementations registered with Serializer.RegisterFormat take precedence over the built-in AnyObjectFormat. When the serializer resolves the concrete type from a $type tag, it calls GetFormatForType(actualType) on the resolved type, so a custom handler registered for Dog will be used even when deserializing into an IAnimal field:
Serializer.RegisterFormat(new DogFormat());
var original = new Dog { Species = "Canine", Breed = "Shiba Inu" };
EchoObject echo = Serializer.Serialize(original, new SerializationContext(TypeMode.Aggressive));
IAnimal? restored = Serializer.Deserialize<IAnimal>(echo);
// DogFormat.Deserialize was called — custom logic runs