Reflection-free binding with the source generator
Bodu.Text.Formats.Generators emits compile-time factories for [DelimitedRecord] and [IniSection] POCOs so that DelimitedSerializer and IniSerializer can bind without reflection. This guide walks one record type and one section type end to end: annotate, build, use the generated factory with the factory overloads, confirm byte parity with the reflection binder, and read the diagnostics. The reference material - csproj wiring, the full rule list, the diagnostics table, trimming - is on the package page.
Note
The generator is not yet published as a package; reference the project as an analyzer (OutputItemType="Analyzer" ReferenceOutputAssembly="false"), together with Bodu.Text.Delimited and/or Bodu.Text.Ini for the marker attributes and factory interfaces.
Step 1 - annotate a partial POCO
The rules that matter: the type is partial, non-generic, has a parameterless constructor, and every mapped member is a public read/write property of a supported scalar type. [PropertyName] renames a column; [Ignore] drops one.
using Bodu.Text.Delimited;
using Bodu.Text.Serialization;
[DelimitedRecord]
public sealed partial class Trade
{
public string Symbol { get; set; } = "";
public int Quantity { get; set; }
public decimal Price { get; set; }
[PropertyName("traded_at")]
public DateTimeOffset TradedAt { get; set; }
public string? Venue { get; set; }
[Ignore]
public string? Notes { get; set; }
}
Step 2 - build, and look at what was generated
Building the project adds Trade.DelimitedFactory. Its Headers are the resolved wire names in declaration order - the renamed column and the omitted Notes member are already visible here:
Trade.DelimitedFactory.Headers // "Symbol", "Quantity", "Price", "traded_at", "Venue"
The emitted source (visible with <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>) is a second partial declaration of Trade holding the static property and a private nested implementation. Abridged:
// <auto-generated/>
partial class Trade
{
public static global::Bodu.Text.Delimited.IDelimitedRecordFactory<Trade> DelimitedFactory { get; } = new GeneratedDelimitedRecordFactory();
private sealed class GeneratedDelimitedRecordFactory : global::Bodu.Text.Delimited.IDelimitedRecordFactory<Trade>
{
private static readonly string[] HeaderNames = new string[] { "Symbol", "Quantity", "Price", "traded_at", "Venue" };
public global::System.Collections.Generic.IReadOnlyList<string> Headers => HeaderNames;
public string[] GetFields(Trade record)
{
return new string[]
{
record.Symbol ?? string.Empty,
record.Quantity.ToString(global::System.Globalization.CultureInfo.InvariantCulture),
record.Price.ToString(global::System.Globalization.CultureInfo.InvariantCulture),
record.TradedAt.ToString(global::System.Globalization.CultureInfo.InvariantCulture),
record.Venue ?? string.Empty,
};
}
public Trade Create(string[] fields, global::System.Collections.Generic.IReadOnlyList<string> headers)
{
var result = new Trade();
if (headers.Count == 0)
{
// headerless document: bind positionally in HeaderNames order
// …
return result;
}
int limit = fields.Length < headers.Count ? fields.Length : headers.Count;
for (int i = 0; i < limit; i++)
Bind(result, headers[i], fields[i]);
return result;
}
private static void Bind(Trade result, string name, string value)
{
switch (name)
{
case "Quantity":
result.Quantity = int.Parse(value, global::System.Globalization.CultureInfo.InvariantCulture);
return;
// … one case per column, then a case-insensitive fallback …
}
}
}
}
Every conversion is explicit and invariant-culture; nothing in the file touches System.Reflection.
Step 3 - serialize and deserialize through the factory
The factory overloads take the factory as an extra argument and are otherwise the same entry points:
using Bodu.Text.Delimited;
var trades = new List<Trade>
{
new() { Symbol = "MSFT", Quantity = 100, Price = 412.5m, TradedAt = new DateTimeOffset(2026, 3, 2, 9, 30, 0, TimeSpan.Zero), Venue = "XNAS", Notes = "internal" },
new() { Symbol = "AAPL", Quantity = 25, Price = 189.99m, TradedAt = new DateTimeOffset(2026, 3, 2, 9, 31, 0, TimeSpan.Zero), Venue = null },
};
string csv = DelimitedSerializer.Serialize(trades, Trade.DelimitedFactory);
// Symbol,Quantity,Price,traded_at,Venue
// MSFT,100,412.5,03/02/2026 09:30:00 +00:00,XNAS
// AAPL,25,189.99,03/02/2026 09:31:00 +00:00,
List<Trade> back = DelimitedSerializer.Deserialize(csv, Trade.DelimitedFactory);
// back[0].TradedAt → 2026-03-02T09:30:00+00:00; back[1].Venue → null (empty field)
Dialect options apply unchanged - a TSV or headerless variant is one options object away:
var tsv = new DelimitedSerializerOptions { Delimiter = '\t' };
string tab = DelimitedSerializer.Serialize(trades, Trade.DelimitedFactory, tsv);
var headerless = new DelimitedSerializerOptions { NoHeader = true };
List<Trade> positional = DelimitedSerializer.Deserialize(
"IBM,10,1.5,2026-01-01T00:00:00+00:00,\n", Trade.DelimitedFactory, headerless);
// binds by position in Headers order → Symbol = "IBM", Quantity = 10
Step 4 - the same for an INI section
using Bodu.Text.Ini;
using Bodu.Text.Serialization;
[IniSection]
public sealed partial class ServerSection
{
public string Host { get; set; } = "";
public int Port { get; set; }
[PropertyName("use_tls")]
public bool UseTls { get; set; }
public TimeSpan? Timeout { get; set; }
}
var server = new ServerSection { Host = "db.example.com", Port = 5432, UseTls = true, Timeout = TimeSpan.FromSeconds(90) };
string ini = IniSerializer.SerializeSection("server", server, ServerSection.IniFactory);
// [server]
// Host=db.example.com
// Port=5432
// use_tls=true
// Timeout=00:01:30
ServerSection restored = IniSerializer.DeserializeSection(ini, "server", ServerSection.IniFactory);
// An empty section name addresses the document's global keys instead of a [section].
string globals = IniSerializer.SerializeSection("", server, ServerSection.IniFactory);
// Host=db.example.com
// Port=5432
// …
ServerSection.IniFactory.Keys is Host, Port, use_tls, Timeout. A null nullable writes as an empty value (Timeout=) and an empty value reads back as null. Asking for a section the document does not contain raises IniSerializationException; the configured duplicate-section and duplicate-key policies are applied before the factory sees the entries.
Byte parity with the reflection binder
The generated factory is designed to be a drop-in for the reflection path, and the solution's tests pin that: serializing the same records through DelimitedSerializer.Serialize(records) and DelimitedSerializer.Serialize(records, Trade.DelimitedFactory) yields identical text - same header names, same column order, same invariant scalar formatting, same quoting. The INI factory writes the same canonical [section] / key=value bytes the reflection binder emits for a section POCO.
string reflection = DelimitedSerializer.Serialize(trades); // [RequiresUnreferencedCode]
string generated = DelimitedSerializer.Serialize(trades, Trade.DelimitedFactory); // no annotations
bool same = reflection == generated; // true
The one deliberate divergence: a factory's names are fixed at compile time, so PropertyNamingPolicy on the options is ignored by the factory overloads. Use [PropertyName] to pin wire names, and both paths agree.
Diagnostics walk-through
The generator reports three diagnostics, all in category Bodu.Text.Formats.Generators.
BTFG001 (error) - the type is not partial. The most common first-run failure: the attribute is present, the partial modifier is not. No factory is generated, so Trade.DelimitedFactory also fails to resolve.
[DelimitedRecord]
public sealed class Trade { … } // BTFG001: … is not declared partial, so no factory is generated
A nested type must have every containing type marked partial too.
BTFG002 (warning) - a member type is not a supported scalar. The factory is still generated; the flagged member is left out of Headers / Keys and never written or read. Either move the value to a supported scalar (for example, format a Uri as a string), or drop the member with [Ignore] to silence the warning deliberately.
[DelimitedRecord]
public sealed partial class Order
{
public int Id { get; set; }
public List<string> Tags { get; set; } = []; // BTFG002: type 'List<string>' is not a supported scalar; the generated factory skips it
}
BTFG003 (error) - the type is generic. A static DelimitedFactory property cannot exist on an open generic, and the same applies to a type nested inside a generic type. No source is emitted; close the type over its arguments or write the factory by hand.
[IniSection]
public sealed partial class Section<T> { … } // BTFG003: … is generic (or nested in a generic type), so no factory is generated
When the generator cannot be used
Implement IDelimitedRecordFactory<TRecord> or IIniSectionFactory<TSection> yourself - the package page shows a complete hand-written Trade factory that is byte-identical to the generated one. The factory overloads accept any implementation.
See also
- Bodu.Text.Formats.Generators - the package page: wiring, rules, diagnostics table, trimming and AOT.
- Using delimited (CSV / TSV) and Using INI - the reflection-path patterns the factories mirror.
- Streams and token-level I/O - the
Utf8*Reader/Utf8*Writersurface underneath both binders.