Skip to main content

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 Prowl.Echo serializes an object graph, it only knows the declared types of fields and parameters — the types that appear in your source code. At runtime, those slots can hold derived classes, interface implementations, or any other subtype. Without recording the actual runtime type, deserialization has no way to know which concrete class to instantiate. Type preservation is how Echo bridges that gap: by embedding type names directly in the serialized EchoObject, it guarantees that Dog stays a Dog even after a round-trip through a field declared as Animal.

Why It Matters: The Polymorphism Problem

Consider a list of animals serialized without any type information:
public abstract class Animal { public string Name { get; set; } = ""; }
public class Dog : Animal   { public string Breed  { get; set; } = ""; }
public class Cat : Animal   { public bool   Indoor { get; set; } }

var animals = new List<Animal>
{
    new Dog { Name = "Rex",   Breed = "Labrador" },
    new Cat { Name = "Mochi", Indoor = true },
};
When Echo walks the list it sees elements of declared type Animal, but the runtime types are Dog and Cat. Without embedding those names, deserialization would try to instantiate Animal — an abstract class — and throw. Type preservation records "MyAssembly.Dog, MyAssembly" and "MyAssembly.Cat, MyAssembly" in the graph so that the correct constructor is called on the way back.

The Three TypeMode Values

1

TypeMode.Auto — embed only when necessary (default)

Echo compares the actual runtime type of each value against the declared target type. If they differ, a type envelope is added. If they match exactly, no envelope is written — keeping the output lean for the common case.
// Field declared as Animal, value is Dog → type IS embedded
// Field declared as Dog,    value is Dog → type is NOT embedded
var ctx  = new SerializationContext(TypeMode.Auto); // default
var echo = Serializer.Serialize(myObject, ctx);
2

TypeMode.Aggressive — always embed type

Every compound object receives a $type field regardless of whether the declared and runtime types match. Use this when consumers cannot rely on schema knowledge and need to reconstruct the exact type unconditionally.
var ctx  = new SerializationContext(TypeMode.Aggressive);
var echo = Serializer.Serialize(myObject, ctx);
3

TypeMode.None — never embed type

Echo never writes type information. Deserialization succeeds only when every declared type is also the concrete type — no interfaces, no abstract classes, no derived instances.
var ctx  = new SerializationContext(TypeMode.None);
var echo = Serializer.Serialize(myConfig, ctx);
// Safe only when all fields use concrete, non-abstract types

How Type Information Is Stored

Echo uses two distinct storage formats depending on whether the preserved type is “simple” (primitives, string, decimal, DateTime, Guid, enums) or “complex” (any reference type or struct that is serialized as a compound).

Compact wrapper for simple types ($t / $v)

When a primitive or simple value must carry type information, Echo wraps it in a two-key compound:
{
    "$t": "i",
    "$v": 42
}
$t holds the compact type token ("i" means System.Int32). $v holds the raw EchoObject value.

Full wrapper for complex types ($type)

For reference types serialized as compound objects, the type name is injected directly as a peer key:
{
    "$type": "MyAssembly.Dog, MyAssembly, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "Name":  "Rex",
    "Breed": "Labrador"
}
Because the type name lives inside the same compound as the data, no extra nesting is introduced for the common case. If the data itself is not a compound (for example, a boxed collection), Echo adds an extra $value key to hold the data alongside $type.
You can inspect the stored type of any EchoObject before deserializing by calling GetStoredType(). It reads the $type field and resolves it through TypeNameRegistry without triggering any deserialization. See the EchoObject reference for the security implications.

TypeNameRegistry: How Echo Resolves Type Names

TypeNameRegistry is the static registry that translates between System.Type objects and their serialized string forms. It maintains separate caches for compact names and full names.

Predefined compact names

For the most common types, Echo uses single-character or two-character tokens to minimise output size:
.NET TypeCompact Token
inti
strings
boolb
floatf
doubled
longl
bytey
sbyteY
shorth
ushortH
uintI
ulongL
decimalm
charc
DateTimedt
Guidg
Enums use the prefix e: followed by the type name (e.g. e:Direction). Arrays append [] notation (e.g. i[] for int[]). Generic types use angle-bracket notation (e.g. List<i> for List<int>, Dictionary<s,i> for Dictionary<string, int>).

Full type names for complex types

Complex types are stored using Type.AssemblyQualifiedName — the fully-qualified assembly string that .NET’s Type.GetType() can resolve:
MyGame.Entities.Dog, MyGame, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
TypeNameRegistry caches both directions (type → string and string → type) in ConcurrentDictionary instances, so repeated serialization of the same type incurs the lookup cost only once.

Polymorphic Deserialization in Practice

public abstract class Animal
{
    public string Name { get; set; } = "";
}

public class Dog : Animal
{
    public string Breed { get; set; } = "";
}

public class Cat : Animal
{
    public bool Indoor { get; set; }
}

public class Zoo
{
    public List<Animal> Animals { get; set; } = new();
}

// --- Serialize ---
var zoo = new Zoo
{
    Animals =
    {
        new Dog { Name = "Rex",   Breed = "Labrador" },
        new Cat { Name = "Mochi", Indoor = true },
    }
};

// TypeMode.Auto embeds $type for Dog and Cat because the list's
// element type (Animal) differs from the runtime types.
EchoObject echo = Serializer.Serialize(zoo);

// --- Inspect the stored types before deserializing ---
EchoObject dogNode = echo.Find("Animals/0")!;
Type? storedType = dogNode.GetStoredType(); // typeof(Dog)

// --- Deserialize ---
Zoo restored = Serializer.Deserialize<Zoo>(echo)!;
Console.WriteLine(restored.Animals[0] is Dog); // True
Console.WriteLine(restored.Animals[1] is Cat); // True
Console.WriteLine(((Dog)restored.Animals[0]).Breed); // "Labrador"

Security Validation with GetStoredType()

In networked applications where EchoObject data arrives from an untrusted source, always validate the embedded type before deserializing:
EchoObject incoming = ReceiveFromNetwork();

Type? claimed = incoming.GetStoredType();

// Only accept types that implement IPacket
if (claimed == null || !typeof(IPacket).IsAssignableFrom(claimed))
{
    Log.Warning("Rejected packet with unexpected type: {Type}", claimed?.Name ?? "unknown");
    return;
}

IPacket packet = Serializer.Deserialize<IPacket>(incoming)!;
packet.Handle(connection);
Without this guard, a malicious client could embed an arbitrary type name — such as a type with a resource-allocating constructor — and trigger it on the server simply by sending a crafted payload. GetStoredType() reads the string and resolves the Type object without executing any constructor, making it safe to call before committing to deserialization.

Choosing the Right TypeMode

Use Auto when…

You have polymorphic fields, interface fields, or abstract base classes anywhere in your object graph. This is the safe default for nearly all applications.

Use Aggressive when…

You need every object to be self-describing — for example, generic message-bus payloads or plugin systems where the receiver schema is not known at compile time.

Use None when…

You are serializing simple, flat configuration or value objects where every declared type is a concrete sealed class or primitive. Produces the smallest, simplest output.

Build docs developers (and LLMs) love