Map values to C# types
Dynamic results are convenient when exploring data, but most application code is easier to maintain with normal C#
types. ReadValue<T> decodes using the same layout rules as ReadValue and maps the value to T. The mapping is
checked: values are not silently truncated to make them fit. This is member-by-member conversion, not copying a
native C struct into an identically shaped C# object; C# member offsets and attributes do not define the binary
format.
A class becomes a mapping target with one attribute:
// The generator writes ReadFrom, WriteTo, and the registration for this partial class.
[CStructMapped]
public sealed partial class MappedPoint
{
public short X { get; set; }
public short Y { get; set; }
}
[CStructMapped] asks the CStructSharp source generator to implement ICStructMapped<T> for the partial
class at build time: a static ReadFrom(StructValue) that builds an instance, a static WriteTo(T, StructValue)
that stores one, and a module initializer that registers the type with MappedTypes. Nothing is discovered by
reflection, so the same class works unchanged under trimming and Native AOT. A mapper reads only the members it
names, which lets a class select the values the application needs and ignore the rest.
Map a struct step by step
This layout stores a signed two-dimensional point:
struct point {
int16 x;
int16 y;
};
The C# class uses short, which is the CLR name for a signed 16-bit integer. Read it with:
private static void MapMapped()
{
var layout = new CStruct("struct point { int16 x; int16 y; };");
byte[] bytes = [0xFE, 0xFF, 0x05, 0x00];
// The generated mapper matches X to x by name (case-insensitively) and converts with Get<short>'s checks.
MappedPoint point = layout.ReadValue<MappedPoint>(bytes, "point");
Equal((short)-2, point.X);
Equal((short)5, point.Y);
// The same class writes back through the generated WriteTo.
SequenceEqual(bytes, layout.Serialize("point", point));
}
The four little-endian input bytes are:
FE FF 05 00
└─ -2 ┘└─ 5 ┘
ReadValue<MappedPoint> produces MappedPoint { X = -2, Y = 5 }, and Serialize("point", point) writes the
same four bytes back.
Each property finds its layout member by name: the exact spelling first, then a case-insensitive match (X
finds x), then a match that ignores underscores (BitDepth finds bit_depth). [CStructMember("name")] on a
property names the member explicitly. With [CStructMapped(Layout = "point")] the generator checks the names
against a [CStructLayout] class in the same project at build time and warns (CSG102) about a property that
matches nothing; without it the names are resolved when the value is read, and a name the layout does not
declare raises a CStructPathException that lists the members the struct does have. The
mapped classes lesson covers lists, nested classes, enums, and pointers.
What the generator writes
The generated code is ordinary C#; this is the same class written by hand, which is also how a class in a project without the generator becomes a mapping target:
public sealed class Point : ICStructMapped<Point>
{
public short X { get; set; }
public short Y { get; set; }
// The mapper names the layout members it reads; Get<T> converts each one with range checks.
public static Point ReadFrom(StructValue source)
{
return new Point { X = source.Get<short>("x"), Y = source.Get<short>("y") };
}
public static void WriteTo(Point value, StructValue target)
{
target["x"] = value.X;
target["y"] = value.Y;
}
// Runs before any other code in the assembly; the [CStructMapped] generator emits the same registration.
[ModuleInitializer]
internal static void Register()
{
MappedTypes.Register<Point>();
}
}
private static void MapPoco()
{
var layout = new CStruct("struct point { int16 x; int16 y; };");
Point point = layout.ReadValue<Point>(new byte[] { 0xFE, 0xFF, 0x05, 0x00 }, "point");
Equal((short)-2, point.X);
Equal((short)5, point.Y);
}
Member names in a hand-written mapper are exact and case-sensitive - source.Get<short>("x") reads the layout's
x. The registration runs from a module initializer, a method the runtime calls before any other code in the
assembly. Register there (or once at startup), never from a static constructor: a static constructor that nothing
else triggers is removed by the Native AOT compiler, and the first ReadValue<Point> would report the type as not
mapped.
Other supported targets
ReadValue<T> and Get<T> share one set of conversions:
- integral values when the source fits the destination's range;
- floating-point and decimal targets through checked invariant conversion;
EnumValueResultto a CLR enum, including an unknown numeric value;- arrays (
T[]) by converting each item, so an array of structs becomes an array of mapped classes; - nested structs into nested mapped classes, through their own
ReadFrom; and StructValue,UnionValue,Pointer,string, andobjectas they are.
Null is accepted only when the target is a reference type or a nullable value type. A mapper is free to go further -
copy an array into a List<T>, pick a union member from the UnionValue, follow a Pointer - because it is
ordinary C# code.
Handle expected failures
Use ReadValue<T> when invalid input should throw a CStructReadException. Use TryReadValue<T> when malformed or
truncated input is an ordinary result:
if (layout.TryReadValue<Header>(bytes, out Header? header, "header"))
{
Console.WriteLine(header.Length);
}
else
{
Console.WriteLine("The header is incomplete or invalid.");
}
The equivalent branch is compiled in the first-parse example. TryReadValue<T> catches
only categorized CStructSharp failures. It does not hide invalid arguments, cancellation, or unrelated application
bugs.
One member of a value you already parsed has the same pair on StructValue and UnionValue: Get<T> throws,
TryGet<T>(path, out value) returns false, TryGet<T>(path, out value, out CStructException? failure) also
hands over the path or read exception Get<T> would have thrown, and GetOrDefault<T>(path, fallback) returns the
fallback in either case - an absent conditional member and a value that does not fit the type look the same to
it. A generated layout class reads the root into a mapped class without naming it: Wire.ReadValue<HeaderRecord>(bytes)
and Wire.TryReadValue<HeaderRecord>(bytes, out record) forward to Layout.ReadValue<T>(bytes, "header")
(mapped classes).
Common mapping failures
When mapping fails, inspect the exception path (root.leaves[0].v names the member whose conversion failed, even
inside a nested mapper) and then check:
- Is the class registered?
ReadValue<T>of an unregistered class fails with a message naming the type; a[CStructMapped]class registers itself, a hand-written one must. - Does every property (or hand-written
Get<T>call) name a member the layout declares? - Can every numeric value fit its
Get<T>target type? - Is null being read only into a nullable target?
- Is every nested class the mapper asks for a registered mapped class itself?
If the failure is unclear, first read the same path without <T>. Seeing the direct result and its runtime type
usually reveals whether the problem is binary decoding or C# mapping.
Trimming and Native AOT
The library ships as IsTrimmable and (for .NET 10) IsAotCompatible, and a published Native AOT program runs
every operation, including typed reads and writes from mapped classes. Mapping is the code in ReadFrom and
WriteTo, generated or hand-written, so there is no reflection and no annotation to add; the one rule for a
hand-written mapper is to register from a module initializer, as above. dynamic access is JIT-only. Trimming and Native AOT has the details;
tests/CStructSharp.AotConsumer runs those cases in CI.
Next, read Write and serialize values to use mapped classes and parsed values as
output.
The generated ReadValue<T> reference
lists the exact overload and exceptions.