Skip to main content

Trimming and Native AOT

The .NET 10 build of the package is marked safe to trim and to compile ahead of time. Most methods use no reflection at all. About twenty members do, and they are annotated, so the compiler warns you at the exact call that can break. Read this page before you set PublishTrimmed or PublishAot in an app that uses the library.

At a glance​

MethodWhat it does
ToJsonString, FromJson<T>Reflection-based JSON. Warn with IL2026 and IL3050; throw at run time when reflection-based JSON is off
ToSafeJson, ToCanonicalJson, ContentHashReflection-based JSON with fixed options. Warn with IL2026 and IL3050
DeepClone<T>Copies through reflection-based JSON. Warns with IL2026 and IL3050
MapTo<TDestination>, UpdateFrom<T>Copy properties between types by reflection. Warn with IL2026 and IL3050
SortBy (4 overloads)Sorts by property names in a string. Warns with IL2026 and IL3050
CompareProperties<T>, ApplyToDiff and replay property values by reflection. Warn with IL2026
AsDictionary, ToQueryString, TrimStringPropertiesRead or write every property of the runtime type. Warn with IL2026
GetPropertyValue, GetPropertyValue<T>, SetPropertyValueRead or write one property by name. Warn with IL2026
FormatWith, WithQueryRead placeholders or query values from an object's properties. Warn with IL2026
ToTable, ToMarkdownTable, ToHtmlTable, ToCsvReflect over typeof(T); T is annotated, so no warning and nothing is trimmed away
ToObject<T>Sets properties of T by name; T is annotated
GetInterfacesAndAbstractClasses, IsAssignableToGenericType, GetGenericArgumentsOfType receiver annotated to keep interfaces
GetDefaultValue, IsRecordType receiver annotated to keep the constructor or methods they read

What IsAotCompatible means​

The library project sets <IsAotCompatible>true</IsAotCompatible> for its net10.0 target. That one property turns on the trim, AOT and single-file analyzers while the library is built, and marks the assembly as trimmable. The build treats warnings as errors, so every reflection call inside the library is either annotated or proven safe. For you this means three things:

  • Methods without annotations are safe in a trimmed or Native AOT app. You get no warning and they behave the same.
  • Methods with [RequiresUnreferencedCode] or [RequiresDynamicCode] produce a warning at your call site. The warning is the signal to read the next sections.
  • Generic methods whose T carries [DynamicallyAccessedMembers] tell the trimmer which members of your type to keep. They work after trimming without any warning, as long as the trimmer can see T.
Program.cs in an app with PublishAot set to true
Order order = new Order("INV-001", 99.5m);

string json = order.ToJsonString(); // warnings IL2026 and IL3050 on this line
string csv = new[] { order }.ToCsv(); // no warning
bool paid = "Paid".EqualsIgnoreCase("paid"); // no warning

public sealed record Order(string Number, decimal Total);
Output
warning IL2026: Using member 'System.ObjectExtensions.ToJsonString(Object, JsonSerializerOptions)' which has
'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. This method uses
reflection-based System.Text.Json serialization, which trimming and ahead-of-time compilation can break.
Serialize with a source-generated JsonSerializerContext instead.
warning IL3050: Using member 'System.ObjectExtensions.ToJsonString(Object, JsonSerializerOptions)' which has
'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. ...

The library uses two warning messages. JSON members say:

JSON message
This method uses reflection-based System.Text.Json serialization, which trimming and ahead-of-time compilation
can break. Serialize with a source-generated JsonSerializerContext instead.

Reflection members say:

Reflection message
This method reads the object's properties through reflection, which trimming can remove. Keep the type's public
properties with [DynamicallyAccessedMembers] or a TrimmerRootDescriptor.
Which build your app uses

The package ships netstandard2.1 and net10.0 builds. An app that targets .NET 10 gets the net10.0 build, the one marked IsAotCompatible. An app on .NET 8 or .NET 9 gets the netstandard2.1 build. That build carries the same annotations (the attributes are polyfilled), but it is not marked AOT-compatible and was not checked by the analyzers. Target .NET 10 if you publish trimmed or with Native AOT.

Members that need reflection​

These are all the public members with [RequiresUnreferencedCode] (warning IL2026) or [RequiresDynamicCode] (warning IL3050). Everything else in the library has neither attribute.

Reflection-based JSON​

These members call System.Text.Json without a source-generated context. All six carry both attributes.

MemberIL2026IL3050Details
ToJsonString(this object? obj, JsonSerializerOptions? options = null)yesyesJSON
FromJson<T>(this string json, JsonSerializerOptions? options = null)yesyesJSON
ToSafeJson(this object? obj, params string[] additionalSensitiveNames)yesyesSafe logging
ToCanonicalJson(this object? obj)yesyesCanonical JSON
ContentHash(this object? obj)yesyesContent hash
DeepClone<T>(this T source)yesyesCompare, clone and map

PrettyJson, MinifyJson and IsValidJson work on JSON text with JsonDocument. They carry no attributes and are safe to use in a trimmed or AOT app.

Mapping between types​

MapTo and UpdateFrom read and write properties by reflection and build generic collection types at run time. Both carry both attributes.

MemberIL2026IL3050Details
MapTo<TDestination>(this object source)yesyesMapTo
UpdateFrom<T>(this T target, object source, bool ignoreNullValues = true)yesyesUpdateFrom

Sorting by name​

SortBy resolves property names from a string such as "-total,customer.name" and builds OrderBy / ThenBy calls at run time. All four overloads carry both attributes.

MemberIL2026IL3050
SortBy<T>(this IEnumerable<T> source, string? sort)yesyes
SortBy<T>(this IEnumerable<T> source, string? sort, params string[] allowedProperties)yesyes
SortBy<T>(this IQueryable<T> source, string? sort)yesyes
SortBy<T>(this IQueryable<T> source, string? sort, params string[] allowedProperties)yesyes

See Paging and sorting for an AOT-safe alternative with a switch and plain OrderBy calls.

Property access by reflection​

These members read the properties of the object's runtime type. They carry only [RequiresUnreferencedCode]: they need the properties to survive trimming, but generate no code at run time.

MemberIL2026IL3050Details
CompareProperties<T>(this T before, T after)yesnoCompareProperties
ApplyTo(this IEnumerable<PropertyChange> changes, object target)yesnoApplyTo
AsDictionary(this object source, BindingFlags bindingAttr = ...)yesnoAsDictionary
ToQueryString(this object source, string? collectionSeparator = null)yesnoToQueryString
TrimStringProperties(this object source)yesnoTrimStringProperties
GetPropertyValue(this object target, string propertyName)yesnoGetPropertyValue
GetPropertyValue<T>(this object target, string propertyName)yesnoGetPropertyValue
SetPropertyValue(this object target, object? value, string propertyName)yesnoSetPropertyValue
FormatWith(this string template, object source, IFormatProvider? provider = null)yesnoFormatWith
WithQuery(this Uri uri, object parameters)yesnoWithQuery
note

The attribute is on the method, so the warning appears even when you pass a dictionary to FormatWith or WithQuery, or when the type's properties are already kept. The dictionary ToQueryString (on IDictionary<string, object?>) is a different method and carries no attribute.

Generic members that keep their own properties​

Some members only reflect over a type they can name at compile time: the T of the call, or the Type you pass. Instead of a warning, they annotate that type parameter with [DynamicallyAccessedMembers]. The trimmer then keeps the listed members of the type you use. You get no warning and no missing columns.

ToTable, ToMarkdownTable, ToHtmlTable and ToCsv​

The four table writers read the public properties of typeof(T). T is annotated with DynamicallyAccessedMemberTypes.PublicProperties, so the trimmer keeps every public property of T, even ones your code never reads directly.

CSV export in a trimmed app
InvoiceLine[] lines = { new InvoiceLine("A-100", 2), new InvoiceLine("B-200", 5) };

string csv = lines.ToCsv();
// → "Sku,Quantity\r\nA-100,2\r\nB-200,5\r\n"

public sealed record InvoiceLine(string Sku, int Quantity);
The static type decides the columns

These methods use typeof(T), not the runtime type of each item. A List<object> gives T = object, which has no public properties, so the table has no columns. A List<Animal> that holds Dog items shows only the properties of Animal. Pass a sequence typed as the class whose columns you want.

The output format of each writer is on Console output, including ToCsv.

ToObject​

ToObject<T> creates a T and sets each public writable property whose name matches a dictionary key. T is annotated with PublicProperties. That matters most here: a key whose property was trimmed away is silently ignored, and the annotation is what prevents that.

Build a settings object from a row
IDictionary<string, object?> row = new Dictionary<string, object?>
{
["Plan"] = "team",
["Seats"] = "42",
["BillingDay"] = "Monday",
};

Subscription subscription = row.ToObject<Subscription>();
// subscription.Plan → "team"
// subscription.Seats → 42
// subscription.BillingDay → DayOfWeek.Monday

public sealed class Subscription
{
public string? Plan { get; set; }

public int Seats { get; set; }

public DayOfWeek BillingDay { get; set; }
}

See Dictionaries for conversion rules and exceptions.

Passing T through your own generic code​

The annotation only works when the trimmer knows T. If you wrap one of these methods in your own generic method, your T must carry the same annotation. Otherwise the trim analyzer reports IL2091 on the inner call.

A generic export helper
using System.Diagnostics.CodeAnalysis;

string csv = ReportExporter.ExportCsv(new[] { new InvoiceLine("A-100", 2) });
// → "Sku,Quantity\r\nA-100,2\r\n"

public static class ReportExporter
{
// Without the attribute on T, the call to ToCsv reports warning IL2091.
public static string ExportCsv<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(
IEnumerable<T> rows)
{
return rows.ToCsv();
}
}

Type helpers​

Five Type extensions annotate the Type receiver. Calling them on typeof(SomeType) is safe after trimming. If you pass a Type from a variable or from GetType(), the analyzer can warn on your call, because it cannot see which type it is.

MemberMembers kept
GetInterfacesAndAbstractClassesInterfaces
IsAssignableToGenericTypeInterfaces
GetGenericArgumentsOfInterfaces
GetDefaultValuePublicParameterlessConstructor
IsRecordPublicMethods, NonPublicMethods
Type checks that survive trimming
Type[]? itemTypes = typeof(List<int>).GetGenericArgumentsOf(typeof(IEnumerable<>));
// → [typeof(int)]

object? emptyId = typeof(Guid).GetDefaultValue();
// → Guid.Empty

bool isRecord = typeof(Order).IsRecord();
// → true

What happens at run time​

A warning does not stop the build (unless your project treats warnings as errors). It tells you what can go wrong after publishing.

Reflection-based JSON is off​

When the project sets PublishTrimmed or PublishAot, the .NET SDK sets JsonSerializerIsReflectionEnabledByDefault to false. This applies to every build of the app, not only the published output, so dotnet run already has reflection-based JSON off. System.Text.Json then refuses to serialize with options that have no TypeInfoResolver. In that app:

  • ToJsonString and FromJson<T> without options throw InvalidOperationException. The message says that reflection-based serialization has been disabled and asks for the source generator or a TypeInfoResolver.
  • ToSafeJson, ToCanonicalJson and ContentHash always use their own options, which have no resolver, so they throw InvalidOperationException too. There is no way to pass a context to them.
  • DeepClone builds its own DefaultJsonTypeInfoResolver, so it does not hit that switch. It still depends on reflection over members the trimmer may have removed, so a copy can miss data. Do not rely on it in a trimmed app.
A trimmed app with default settings
Order order = new Order("INV-001", 99.5m);

string json = order.ToJsonString();
// → throws InvalidOperationException (reflection-based serialization is disabled)
Tests do not show this

Unit tests run in a test project that sets neither PublishTrimmed nor PublishAot, so reflection-based JSON is on there. Run the app itself, or set <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault> in the test project, to see the failure before production does.

Reflection over trimmed properties​

The reflection members in Property access by reflection do not throw because of trimming itself. They see fewer properties. The trimmer removes a property getter or setter that no code calls directly, so:

  • AsDictionary, ToQueryString and CompareProperties return fewer entries.
  • TrimStringProperties skips properties it can no longer find.
  • GetPropertyValue and SetPropertyValue throw ArgumentException for a name that no longer exists, the same exception as for a misspelled name.
  • FormatWith throws FormatException for a placeholder whose property was removed.

MapTo, UpdateFrom and SortBy also carry [RequiresDynamicCode]. In a Native AOT app they can fail at run time when they need a generic type or method that was not compiled ahead of time.

Use source-generated JSON instead​

The JSON warnings go away when you call System.Text.Json with a source-generated JsonSerializerContext. The generator writes the serialization code at compile time, so nothing needs reflection at run time.

Declare a context​

List every type you serialize, including the collection types, on a partial class that derives from JsonSerializerContext.

AppJsonContext.cs
using System.Collections.Generic;
using System.Text.Json.Serialization;

[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
internal partial class AppJsonContext : JsonSerializerContext
{
}

Replace ToJsonString and FromJson​

Call JsonSerializer with the generated JsonTypeInfo<T> for the type.

Serialize and deserialize without reflection
using System.Text.Json;

Order order = new Order("INV-001", 99.5m);

string json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
// → "{\"Number\":\"INV-001\",\"Total\":99.5}"

Order? parsed = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);
// parsed → Order { Number = "INV-001", Total = 99.5 }

Replace DeepClone​

Round-trip through the same context. For a record whose properties are all immutable values, order with { } is already a full copy.

A deep copy with the source generator
using System.Text.Json;

List<Order> orders = new List<Order> { new Order("INV-001", 99.5m), new Order("INV-002", 12m) };

byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(orders, AppJsonContext.Default.ListOrder);

// '!' is safe: the JSON of a non-null list is never the literal null.
List<Order> copy = JsonSerializer.Deserialize(bytes, AppJsonContext.Default.ListOrder)!;
// copy.Count → 2
// ReferenceEquals(copy, orders) → false

Passing a context through options​

ToJsonString and FromJson<T> accept JsonSerializerOptions. If you pass options whose TypeInfoResolver is a source-generated context, the call works at run time even with reflection-based JSON off. The warnings stay, because the analyzer cannot see what you pass. Prefer the direct JsonSerializer calls above in new code.

Existing calls, made to work at run time
using System.Text.Json;

JsonSerializerOptions options = AppJsonContext.Default.Options;

string json = order.ToJsonString(options);
// → "{\"Number\":\"INV-001\",\"Total\":99.5}"

Order? parsed = json.FromJson<Order>(options);
// parsed.Number → "INV-001"
Every runtime type must be in the context

ToJsonString serializes obj.GetType(), the runtime type. If that type is not listed in the context, System.Text.Json throws NotSupportedException. Add a [JsonSerializable] line for each derived type you pass.

Keep a type's properties for reflection​

If you must use a reflection member in a trimmed app, keep the properties it reads, then silence the warning at the call with a justification. Mark the class itself with [DynamicallyAccessedMembers]: the trimmer then keeps those members on the class and on classes derived from it.

Keep public properties for AsDictionary
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;

IDictionary<string, object?> fields = AuditWriter.ToFields(new AuditEntry { User = "ahmad", Action = "login" });
// fields["User"] → "ahmad"
// fields["Action"] → "login"

[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
public sealed class AuditEntry
{
public string User { get; set; } = string.Empty;

public string Action { get; set; } = string.Empty;
}

public static class AuditWriter
{
[UnconditionalSuppressMessage(
"Trimming",
"IL2026",
Justification = "AuditEntry keeps its public properties through [DynamicallyAccessedMembers].")]
public static IDictionary<string, object?> ToFields(AuditEntry entry)
{
return entry.AsDictionary();
}
}

A TrimmerRootDescriptor XML file in the app project does the same for types you cannot annotate, such as types from another library.

Suppress only what you have checked

Suppressing IL2026 keeps the build quiet but proves nothing. Do it only when the type's properties are kept, and never suppress IL3050 on MapTo, UpdateFrom or SortBy in a Native AOT app without testing the published output.

See also​