Easier reflection with [UnsafeAccessorType] in .NET 10

Reflection in .NET has always carried a performance tax. You get access to private members, but every call goes through metadata lookups and boxing, and that shows up quickly once you profile it. The [UnsafeAccessor] attribute arrived in .NET 8 to close that gap, and .NET 10 adds a companion attribute called [UnsafeAccessorType] that removes one of its biggest restrictions. This post walks through both attributes, what changed, and where the remaining gaps are.

What [UnsafeAccessor] already solved in .NET 8 and 9

[UnsafeAccessor] shipped in .NET 8, and .NET 9 added support for generic types and methods. It lets you call private fields, methods, properties, and constructors on a type without going through System.Reflection at all. The runtime generates the access code directly at JIT time, so calls run close to the speed of calling a public member.

Say you need the private _items field inside List<T>. Here is a simplified look at that field on the actual BCL type.

public class List<T>
{
    T[]? _items;
    // .. other members
}

You are not normally allowed to touch this field from outside the class. Reflection gets you there, at a cost.

// Get a FieldInfo object for accessing the value
var itemsFieldInfo = typeof(List<int>)
    .GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance);
 
// Create an instance of the list
var list = new List<int>(16);
 
// Retrieve the list using reflection
var items = (int[])itemsFieldInfo.GetValue(list);
 
Console.WriteLine($"{items.Length} items"); // Prints "16 items"

That works, but each call to GetValue does a name lookup behind the scenes and boxes the result. [UnsafeAccessor] avoids this. You declare an extern method carrying the attribute, with a signature matching the target member exactly. For a field, that means a method taking the target type as its only parameter and returning the field’s type as a ref.

// Create an instance of the list
var list = new List<int>(16);
 
// Invoke the method to retrieve the list
int[] items = Accessors<int>.GetItems(list);
 
Console.WriteLine($"{items.Length} items"); // Prints "16 items"
 
static class Accessors<T>
{
    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
    public static extern ref T[] GetItems(List<T> list);
}

The name you give the extern method does not matter, only the Name argument on the attribute does. Because List<T> is generic, you need a generic container class (Accessors<T> here) to hold the accessor. A plain generic method, GetItems<T>(List<T>), will not bind, and neither will a version closed over a specific type such as GetItems(List<int>). This trips people up the first time: they write the accessor as an ordinary static generic method and cannot figure out why the runtime rejects it.

Since the accessor returns a ref, you can assign to it directly, which is what happens below when the backing array gets replaced with an empty one.

// Create an instance of the list
var list = new List<int>(16);
Console.WriteLine($"Capacity: {list.Capacity}"); // Prints "Capacity: 16"
 
// Invoke the method to retrieve the field ref and set the value of the field to an empty array
Accessors<int>.GetItems(list) = Array.Empty<int>();
Console.WriteLine($"Capacity: {list.Capacity}"); // Prints "Capacity: 0"
 
// Same accessor as before:
static class Accessors<T>
{
    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
    public static extern ref T[] GetItems(List<T> list);
}

Worth calling out: an [UnsafeAccessor] on a field always returns by ref, so read and write both go through the same declaration. There is no separate get and set variant.

Fields are not the only target. [UnsafeAccessor] also covers methods, properties (which compile down to methods), and constructors, controlled by this enum.

public enum UnsafeAccessorKind
{
  Constructor,
  Method,
  StaticMethod,
  Field,
  StaticField,
}

Here is an example calling a private static method on List<T>.

// Invoking private static methods
// We can pass `null` as the instance argument because these are static methods
bool isCompat1 = Accessors<int?>.IsCompatibleObject(null, 123); // true
bool isCompat2 = Accessors<int?>.IsCompatibleObject(null, null); // true
bool isCompat3 = Accessors<int?>.IsCompatibleObject(null, 1.23); // false
 
static class Accessors<T>
{
    // The method we're invoking has this signature:
    //     private static bool IsCompatibleObject(object? value)
    // 
    // Our extern signature must include the target type as the first method parameter
    [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "IsCompatibleObject")]
    public static extern bool CheckObject(List<T> instance, object? value);
}

For both instance and static methods, the target instance goes in as the first parameter, with null passed for static calls, so the runtime knows which type’s private member to bind against. That works fine as long as you can name the type in your own code. The problem starts when you cannot.

Where this fell over in .NET 9

The restriction in .NET 9 is that every type appearing in an accessor’s signature must be one you can reference directly from source. Picture a library that ships something like this.

public class PublicClass
{
    private readonly PrivateClass _private = new("Hello world!");
 
    internal PrivateClass GetPrivate() => _private;
}
 
internal class PrivateClass(string someValue)
{
    internal string SomeValue { get; } = someValue;
}

It is a contrived example, but it captures the real problem. PrivateClass is internal, so a consuming assembly cannot spell its name anywhere, which rules out every accessor signature needing it.

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_private")]
static extern ref readonly PrivateClass GetByField(PublicClass instance);
//                         Can't reference PrivateClass
 
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetPrivate")]
static extern PrivateClass GetByMethod(PublicClass instance);
//            Can't reference PrivateClass
 
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_SomeValue")]
static extern string GetSomeValue(PrivateClass instance);
//                               Can't reference PrivateClass

That is the visibility problem. There is a second, rarer case: sometimes the type is not available to you at compile time at all, regardless of accessibility. Two real examples come to mind.

  • The .NET runtime itself hits this due to circular dependencies between libraries, for example between the HTTP and Cryptography libraries.
  • Instrumentation libraries such as Datadog’s need to reach internal properties of the libraries they instrument, but cannot reference those libraries directly because of version compatibility constraints.

Before .NET 10, the only way around either case was to fall back to plain reflection, or reach for System.Reflection.Emit or System.Linq.Expressions. Both work, but they give up most of the speed advantage [UnsafeAccessor] was built for, and the resulting code is noticeably harder to follow.

[UnsafeAccessorType] closes the gap

​.NET 10 adds [UnsafeAccessorType], which lets you describe the type an accessor targets as a string instead of a compile-time reference. That solves both problems above in one move. Take the same PublicClass and PrivateClass pair from earlier.

public class PublicClass
{
    private readonly PrivateClass _private = new("Hello world!");
 
    internal PrivateClass GetPrivate() => _private;
}
 
internal class PrivateClass(string someValue)
{
    internal string SomeValue { get; } = someValue;
}

The accessor methods now look like this.

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetPrivate")]
[return: UnsafeAccessorType("PrivateClass")] // Specify target return type as a string
static extern object GetByMethod(PublicClass instance);
//            use object as return type
 
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_SomeValue")]
static extern string GetSomeValue([UnsafeAccessorType("PrivateClass")] object instance);
// Specify target type in attribute, and use object as instance type

Instead of writing PrivateClass anywhere in the signature, the parameter and return types become object, and [UnsafeAccessorType] carries the real type name as a string. Chain the two calls and you get the private value out without spelling PrivateClass in your own code at all.

// Create the target instance
var publicClass = new PublicClass();
 
// Invoke GetPrivate(), and return the result as an object
object privateClass = GetByMethod(publicClass);
 
// Pass the object and invoke the SomeValue getter method
string value = GetSomeValue(privateClass);
Console.WriteLine(value); // Hello world!

That is the headline feature. Anywhere you were previously blocked by an inaccessible or unreferenceable type, you now have a path that avoids traditional reflection entirely.

Getting the type name string right

The type name is not just the short class name, even though PrivateClass in the example above looks like it. It has to be a fully qualified name, the same format you would pass into Type.GetType(name). Assembly qualification is not mandatory, but adding it makes resolution more reliable once you have multiple assemblies with similarly named types.

Generic types and methods need extra care. The string must use the open or closed generic format depending on how you are calling it, something like List`1[[!0]], and nested classes need a plus separator instead of a dot. This is fiddly to get right by hand. I would test against a known generic case before wiring this into production code, since a malformed string fails at runtime, not at compile time.

The runtime’s own unit tests for [UnsafeAccessor] have a solid set of examples. All the types below live in an assembly called PrivateLib and are marked internal, so none of them can be referenced directly.

namespace PrivateLib;
 
internal class Class1
{
    static int StaticField = 123;
    int InstanceField = 456;
 
    Class1() { }
 
    static Class1 GetClass() => new Class1();
 
    private Class1[] GetArray(ref Class1 a) => new[] { a };
}
 
internal class GenericClass<T>
{
    List<Class1> ClosedGeneric() => new List<Class1>();
 
    List<U> GenericMethod<U>() => new List<U>();
 
    bool GenericWithConstraints<V, W>(List<T> a, List<V> b, List<W> c, List<Class1> d)
        where W : T
         => true;
}

Here are representative accessor declarations covering constructors, static methods, static and instance fields, ref array returns, and generic methods with constraints, arranged in increasing order of complexity.

// 1. Calling the Class1 constructor, returned type name is assembly qualified
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
[return: UnsafeAccessorType("PrivateLib.Class1, PrivateLib")]
extern static object CreateClass();
 
// 2. Calling a static method. Both the return type and the target parameter are assembly qualified
[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "GetClass")]
[return: UnsafeAccessorType("PrivateLib.Class1, PrivateLib")]
extern static object CallGetClass([UnsafeAccessorType("PrivateLib.Class1, PrivateLib")] object a);
 
// 3. Returning a ref to the static field
[UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "StaticField")]
extern static ref int GetStaticField([UnsafeAccessorType("PrivateLib.Class1, PrivateLib")] object a);
 
// 4. Returning a ref to an instance field
// Note that we cannot use [UnsafeAccessorType] on the return type. More on that shortly.
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "InstanceField")]
extern static ref int GetInstanceField([UnsafeAccessorType("PrivateLib.Class1, PrivateLib")] object a);
 
// 5. Passing an object by reference and returning an array
// Note the & in the signature when passing by reference.
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetArray")]
[return: UnsafeAccessorType("PrivateLib.Class1[], PrivateLib")]
extern static object CallM_RC1(TargetClass tgt, [UnsafeAccessorType("PrivateLib.Class1&, PrivateLib")] ref object a);
 
// 6. Invoking a method on a generic type, and returning a closed generic type
// The return type mixes fully qualified BCL types with assembly qualified types
// !0 indicates an unspecified type parameter on the containing type
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ClosedGeneric")]
[return: UnsafeAccessorType("System.Collections.Generic.List`1[[PrivateLib.Class1, PrivateLib]]")]
extern static object CallGenericClassClosedGeneric([UnsafeAccessorType("PrivateLib.GenericClass`1[[!0]], PrivateLib")] object a);
 
// 7. Invoking a generic method on a generic type
// !!0 indicates an unspecified generic method type parameter; the accessor method itself must be generic
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GenericMethod")]
[return: UnsafeAccessorType("System.Collections.Generic.List`1[[!!0]]")]
extern static object CallGenericClassGenericMethod<U>([UnsafeAccessorType("PrivateLib.GenericClass`1[[!0]], PrivateLib")] object a);
 
// 8. Invoking a generic method, on a generic type, with type constraints
// A more elaborate version of the above, additionally specifying constraints matching the target method
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GenericWithConstraints")]
public extern static bool CallGenericClassGenericWithConstraints<V, W>(
    [UnsafeAccessorType("PrivateLib.GenericClass`1[[!0]], PrivateLib")] object tgt,
    [UnsafeAccessorType("System.Collections.Generic.List`1[[!0]]")]
    object a,
    [UnsafeAccessorType("System.Collections.Generic.List`1[[!!0]]")]
    object b,
    List<W> c,
    [UnsafeAccessorType("System.Collections.Generic.List`1[[PrivateLib.Class1, PrivateLib]]")]
    object d) where W : T;

Comment 4 in that block matters more than it looks: you cannot mark a field’s return type with [UnsafeAccessorType] itself, only the containing instance parameter. We will come back to why in the next section. The generic examples, 6 through 8, use !0 and !!0 as placeholders for the containing type’s generic parameter and the accessor method’s own generic parameter. Mixing those two up is a common mistake, and the runtime only catches it when the accessor is actually invoked, not at compile time.

These examples show the range of what [UnsafeAccessorType] can reach, and it does so without giving up the speed advantage over reflection that made [UnsafeAccessor] worth adopting in the first place.

What still does not work

Even with [UnsafeAccessorType], three gaps remain where you still need ordinary reflection.

  • You cannot call an accessor on a generic type if you cannot represent its generic type argument in your own code.
  • You cannot apply [UnsafeAccessorType] to a field’s return type.
  • You cannot apply [UnsafeAccessorType] to a ref-returning method’s return type.

Let us look at each with a concrete failing case.

Generic type arguments you cannot name

Suppose you have an internal generic type, plus a second internal type you need to use as its type argument.

internal class Generic<T> { }
internal class Class1 { }

You create an accessor for the constructor.

static class Accessors<T>
{
    [UnsafeAccessor(UnsafeAccessorKind.Constructor)]
    [return: UnsafeAccessorType("Generic`1[[!0]]")]
    public static extern object Create();
}

Calling Accessors<int>.Create() works fine, since int is public and you can name it. But Accessors<Class1>.Create() will not compile, because Class1 is internal and there is no way to pass it as a generic type argument, whatever attribute you put on the method. If your code hits this shape, plain reflection is genuinely the only option, because even constructing the object through Reflection.Emit would not let you interact with it afterward through the same accessor class.

Field returns cannot carry [UnsafeAccessorType]

Extend the example with a type holding fields of the inaccessible type.

internal class Class1 { }
 
internal class Class2
{
    private Class1 _field1 = new();
    private readonly Class1 _field2 = new();
}

It is tempting to write accessors here mirroring the method pattern from earlier.

// Helper for creating a Class2 instance
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
[return: UnsafeAccessorType("Class2")]
static extern object Create();
 
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_field1")]
[return: UnsafeAccessorType("Class1")]
static extern ref object CallField1([UnsafeAccessorType("Class2")] object instance);
 
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_field2")]
[return: UnsafeAccessorType("Class1")]
static extern ref readonly object CallField2([UnsafeAccessorType("Class2")] object instance);

Both declarations compile cleanly, which is the trap. Calling either one throws at runtime instead of failing at compile time.

object class2 = Create();
object field1 = CallField1(class2); // throws System.NotSupportedException: Invalid usage of UnsafeAccessorTypeAttribute
object field2 = CallField2(class2); // throws System.NotSupportedException: Invalid usage of UnsafeAccessorTypeAttribute

The message reads System.NotSupportedException: Invalid usage of UnsafeAccessorTypeAttribute. Nothing in the IDE warns you ahead of time, so if you hit a similar failure, check whether you have put [UnsafeAccessorType] directly on a field accessor’s return value first, since that is the usual cause. This restriction only bites when the field itself needs [UnsafeAccessorType]; an int or string field on the same class would accessor normally without any trouble.

Ref-returning methods hit the same limit

The same restriction applies to a method returning a ref to an inaccessible type.

internal class Class1 { }
 
internal class Class2
{
    private Class1 _field1 = new();
    private ref Class1 GetField1(Class2 a) => ref _field1;
}

An accessor for this method compiles without complaint.

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetField1")]
[return: UnsafeAccessorType("Class1&")] // ref return
static extern ref object CallGetField1([UnsafeAccessorType("Class2")] object instance);

But calling it fails the same way as the field case, with the identical NotSupportedException thrown at the call site rather than at build time. Functionally, this is the field limitation reached through a method instead of direct field access.

Practical takeaways

[UnsafeAccessor] and now [UnsafeAccessorType] fit library and instrumentation code that genuinely needs private state, not something application teams should reach for by default. Reflection based serializers, ORMs mapping to private backing fields, and the kind of dependency injection wiring the runtime does internally are the scenarios where the speed difference over plain reflection actually shows up in a profiler.

If you are writing application code rather than a library, treat any dependency on private members of a type you do not own as a maintenance risk, regardless of which mechanism gets you there. The type owner can rename or remove that private field in any patch release without warning, since it was never part of the public contract. [UnsafeAccessor] makes the access faster, it does not make it safer against that kind of breakage.

Source generators are worth comparing against this approach whenever you have the option. If you control the type at compile time, such as internal types shared across projects in the same solution, a source generator emitting strongly typed accessor code is usually easier to debug than a stringly typed UnsafeAccessorType name that only fails at runtime if the generic syntax is off. Reach for [UnsafeAccessorType] specifically when the type is genuinely outside your control, such as a third party library or a runtime internal you cannot reference at compile time.

Summary

[UnsafeAccessor] gives private member access close to public member speed, and .NET 9 extended it to generics. Its main restriction was that every type in the signature had to be directly referenceable, which ruled out internal types from other assemblies and circular dependency scenarios inside the runtime itself. [UnsafeAccessorType] in .NET 10 removes that restriction for methods, constructors, and static or instance level access, at the cost of moving type name correctness from compile time to runtime.

The gaps that remain are narrow. Fields and ref-returning methods cannot use [UnsafeAccessorType] on their return value, and generic type arguments still need to be nameable in your own code. Outside those three cases, this is a solid replacement for reflection anywhere you were previously blocked purely by type visibility.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading