BrandGhost
C# Reflection: The Complete .NET 10 Guide

C# Reflection: The Complete .NET 10 Guide

C# Reflection: The Complete .NET 10 Guide -- From Basics to Performance to When NOT to Use It

C# reflection is one of those tools that feels like a superpower the first time you discover it. You can inspect types at runtime, invoke methods you never knew existed at compile time, and build deeply flexible systems that wire themselves together dynamically. And then you deploy to production and notice your startup time doubled.

That tension -- immense flexibility vs. real performance costs -- is exactly what this guide addresses. We'll cover the core APIs, the practical patterns, the performance story in .NET 10, and most importantly: when you should walk away from reflection entirely and reach for something better.

Note that .NET 10's Native AOT and trimming tools can remove members accessed only via reflection -- we'll cover how to handle this later in the guide.


What Is C# Reflection?

Reflection is the ability of a program to examine and manipulate its own metadata at runtime. In .NET, this metadata lives in assemblies -- the compiled .dll and .exe files that contain not just your IL code, but rich type information: class names, property names and types, method signatures, attributes, access modifiers, and more.

The System.Reflection namespace gives you the API surface to query all of it. You can:

  • Discover what types an assembly contains
  • Inspect the members (properties, methods, fields, constructors) of any type
  • Read and write property values on object instances
  • Invoke methods dynamically
  • Create instances of types whose names you only know as strings

This powers a huge swath of .NET infrastructure -- serialization libraries, dependency injection containers, ORMs, test frameworks, plugin systems. Understanding it makes you a better consumer of all those tools, and opens doors for building flexible systems yourself.

For a hands-on introduction with additional examples, see Reflection in C#: 4 Simple But Powerful Code Examples.


Core Reflection Types

Type -- The Heart of Reflection

Everything in System.Reflection flows through System.Type. It represents a type's full description: its name, namespace, base class, interfaces, and all its members.

Three ways to get a Type:

// 1. typeof operator -- resolved at compile time, preferred when you know the type
Type t1 = typeof(string);

// 2. GetType() instance method -- available on every object
string value = "hello";
Type t2 = value.GetType();

// 3. Type.GetType() -- by fully qualified name, useful for plugin/dynamic scenarios
Type? t3 = Type.GetType("System.String");
Type? t4 = Type.GetType("MyApp.Services.OrderService, MyApp");

typeof is the best option when the type is known at compile time -- it avoids the string-parsing overhead of Type.GetType() and is resolved by the compiler rather than the runtime.

Assembly -- Container of Types

An Assembly represents a loaded .NET binary. You can enumerate all types it contains, which is the entry point for plugin systems and extensibility frameworks.

// Load the executing assembly
Assembly current = Assembly.GetExecutingAssembly();

// Load by path (plugin scenario)
Assembly plugin = Assembly.LoadFrom("/plugins/MyPlugin.dll");

// Get all public types in an assembly
Type[] types = plugin.GetExportedTypes();

// Find types implementing a specific interface
Type targetInterface = typeof(IPlugin);
IEnumerable<Type> implementations = types
    .Where(t => !t.IsAbstract && targetInterface.IsAssignableFrom(t));

For a deep dive into plugin architecture powered by reflection, see Plugin Architecture in C#: The Complete Guide to Extensible .NET Applications.

PropertyInfo -- Reading and Writing Properties

PropertyInfo represents a single property on a type. You can read its name, type, whether it has a getter or setter, and crucially -- read and write its value on live instances.

public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = string.Empty;
    public decimal Total { get; private set; }
}

// Get all public instance properties
PropertyInfo[] properties = typeof(Order).GetProperties();

foreach (PropertyInfo prop in properties)
{
    Console.WriteLine($"{prop.Name}: {prop.PropertyType.Name} " +
                      $"(CanRead={prop.CanRead}, CanWrite={prop.CanWrite})");
}

// Read a value from an instance
var order = new Order { Id = 42, CustomerName = "Alice" };
PropertyInfo? idProp = typeof(Order).GetProperty("Id");
object? id = idProp?.GetValue(order);  // returns 42 as object

// Write a value
PropertyInfo? nameProp = typeof(Order).GetProperty("CustomerName");
nameProp?.SetValue(order, "Bob");
Console.WriteLine(order.CustomerName);  // Bob

MethodInfo -- Invoking Methods Dynamically

MethodInfo represents a method and lets you invoke it on an instance at runtime.

public class Calculator
{
    public int Add(int a, int b) => a + b;
    private string FormatResult(int result) => $"Result: {result}";
}

var calc = new Calculator();
Type calcType = typeof(Calculator);

// Get a public method and invoke it
MethodInfo? addMethod = calcType.GetMethod("Add");
object? result = addMethod?.Invoke(calc, new object[] { 3, 4 });
Console.WriteLine(result);  // 7

// Access a private method using BindingFlags
MethodInfo? formatMethod = calcType.GetMethod(
    "FormatResult",
    BindingFlags.NonPublic | BindingFlags.Instance);

object? formatted = formatMethod?.Invoke(calc, new object[] { 42 });
Console.WriteLine(formatted);  // Result: 42

ConstructorInfo -- Controlled Instantiation

ConstructorInfo represents a specific constructor overload. It's useful when you need more control than Activator.CreateInstance provides -- particularly for selecting non-default constructors or for building cached fast-path instantiation.

public class Service
{
    public string Name { get; }

    public Service(string name)
    {
        Name = name;
    }
}

// Get constructor that takes a single string parameter
ConstructorInfo? ctor = typeof(Service)
    .GetConstructor(new[] { typeof(string) });

object? instance = ctor?.Invoke(new object[] { "OrderService" });
if (instance is Service svc)
{
    Console.WriteLine(svc.Name);  // OrderService
}

For a detailed comparison of instantiation approaches, see ConstructorInfo - How To Make Reflection in DotNet Faster for Instantiation.


Activator.CreateInstance -- The Quick Instantiation Path

When you need to create an instance of a type you only know at runtime, Activator.CreateInstance is the most concise option.

// Create using default constructor
object? instance = Activator.CreateInstance(typeof(Order));

// Create with constructor arguments
object? service = Activator.CreateInstance(
    typeof(Service),
    "MyService");

// Generic version -- creates and returns typed result
Order? order = Activator.CreateInstance<Order>();

// Create from a type name (plugin scenario)
Type? pluginType = Type.GetType("MyPlugin.OrderProcessor, MyPlugin");
if (pluginType is not null)
{
    object? processor = Activator.CreateInstance(pluginType);
}

For a complete breakdown of Activator.CreateInstance and its gotchas, see Activator.CreateInstance in C# - A Quick Rundown. And if you're choosing between Activator.CreateInstance and Type.InvokeMember, see Activator.CreateInstance vs Type.InvokeMember.


BindingFlags -- Controlling Member Discovery

By default, GetProperties(), GetMethods(), etc. return only public instance members. BindingFlags lets you expand or narrow that scope.

// Everything -- public, non-public, instance, and static
MemberInfo[] allMembers = typeof(Order).GetMembers(
    BindingFlags.Public |
    BindingFlags.NonPublic |
    BindingFlags.Instance |
    BindingFlags.Static);

// Non-public instance fields only (common for testing internals)
FieldInfo[] privateFields = typeof(Order).GetFields(
    BindingFlags.NonPublic | BindingFlags.Instance);

// Static methods
MethodInfo[] staticMethods = typeof(Order).GetMethods(
    BindingFlags.Public | BindingFlags.Static);

A word of caution: accessing private members via reflection is a code smell in production code. It's acceptable in unit test helpers, but if you find yourself routinely reaching into private state of types you own, consider whether those members should be exposed through a well-designed API instead.


Performance in .NET 10 -- The Real Story

Reflection is slow compared to direct code execution. The overhead comes from several sources: metadata lookup, type safety checks, boxing of value types, and the inability to inline. But the gap has narrowed considerably across .NET versions, and smart caching eliminates most of the repeated-lookup cost.

The Naive Pattern (Expensive)

// Calling GetProperty every time -- pays the metadata lookup on every call
public object? ReadProperty(object instance, string propertyName)
{
    return instance.GetType()
        .GetProperty(propertyName)
        ?.GetValue(instance);
}

Cached Reflection (Much Better)

// Cache PropertyInfo arrays keyed by Type -- pay the lookup cost only once
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propertyCache = new();

public PropertyInfo[] GetCachedProperties(Type type)
{
    return _propertyCache.GetOrAdd(
        type,
        t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
}

Upgrading to FrozenDictionary (Available Since .NET 8)

If your cache is populated once at startup and then only read, FrozenDictionary<TKey, TValue> (introduced in .NET 8, further optimized in .NET 9 and .NET 10) gives you faster lookups because it's optimized for read-only access patterns with no locking overhead.

using System.Collections.Frozen;

// Build the cache at startup
private static FrozenDictionary<Type, PropertyInfo[]> BuildPropertyCache(
    IEnumerable<Type> types)
{
    return types.ToFrozenDictionary(
        t => t,
        t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
}

// Usage: _cache is a field initialized once at startup
private readonly FrozenDictionary<Type, PropertyInfo[]> _cache;

FrozenDictionary is ideal here because reflection metadata caches are exactly the write-once, read-many pattern it was designed for.

The Real Cost to Watch

Even with caching, each PropertyInfo.GetValue and SetValue call boxes value types and performs runtime type checks. For hot paths processing thousands of objects per second, these calls add up. That's where the alternatives below come in.


When NOT to Use Reflection

This is arguably the most important section of this guide.

Don't Use It When Types Are Known at Compile Time

If you know the type at compile time, use direct property access, interfaces, or generics. Reflection buys you nothing in that scenario and adds overhead and fragility.

// ❌ Using reflection when you don't need to
PropertyInfo? idProp = typeof(Order).GetProperty("Id");
int id = (int)idProp!.GetValue(order)!;

// ✅ Just access the property
int id = order.Id;

Don't Use It for Hot Paths Without Measurement

Reflection in a tight loop processing tens of thousands of records is a recipe for latency spikes. Profile first. If reflection is the bottleneck, see the alternatives below.

Don't Use It to Break Encapsulation Habitually

If you routinely need to access private state from outside a class, the design is telling you something. Reflect on the design (pun intended) before reflecting on the type.

Don't Use It When Source Generators Are the Right Tool

For DI wiring, serialization, and compile-time code generation scenarios, source generators produce the reflection-equivalent code at build time -- zero runtime cost, full AOT compatibility, and errors at compile time instead of runtime.


Alternatives to Reflection

Cached Delegates and Expression Trees

Compile a Func<T, object> from a PropertyInfo once and call it repeatedly. Execution speed is near-native after JIT compilation.

using System.Linq.Expressions;

public static Func<T, object?> BuildGetter<T>(PropertyInfo prop)
{
    var param = Expression.Parameter(typeof(T), "obj");
    var access = Expression.Property(param, prop);
    var convert = Expression.Convert(access, typeof(object));
    return Expression.Lambda<Func<T, object?>>(convert, param).Compile();
}

// Build once, call many times
Func<Order, object?> getTotal = BuildGetter<Order>(typeof(Order).GetProperty("Total")!);
object? total = getTotal(order);  // fast -- compiled delegate

Source Generators

Source generators run at compile time and emit C# code that does the equivalent of what reflection would do at runtime. Libraries like System.Text.Json use this for serialization, and the Automatic Dependency Injection in C#: The Complete Guide to Needlr approach replaces runtime DI scanning with generated registration code. The trade-off discussion between both approaches is covered in Source Generation vs Reflection in Needlr: Choosing the Right Approach.

UnsafeAccessor (.NET 8+)

[UnsafeAccessor] is a relatively new attribute that lets you access private members of types you don't own -- at native speed, with AOT compatibility. It's the right tool when you previously would have reached for reflection to access internal state.

// Access a private field directly -- no reflection, no boxing, AOT-safe
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_internalState")]
static extern ref string GetInternalState(MyType obj);

Interfaces and Generics

The most fundamental alternative: design your abstractions so you don't need runtime type discovery. Interfaces give you polymorphism without reflection. Generics give you type-parameterized behavior without object casts.

Reflection's usefulness in DI containers, for example, is partly a consequence of the fact that those containers are wiring together types they couldn't possibly know about at compile time. But if you control both sides of the contract, you can often avoid reflection entirely by surfacing an interface. See IServiceCollection in C# -- Complete Guide for how the DI system itself is structured.


Practical Example -- A Simple Property Mapper

Pulling the concepts together: a stripped-down property mapper that copies matching property values from one object to another. This is the kind of scenario where reflection is genuinely appropriate.

public static class PropertyMapper
{
    // Cache keyed by (source type, destination type) pair
    private static readonly ConcurrentDictionary<(Type, Type), (PropertyInfo Source, PropertyInfo Dest)[]>
        _mappingCache = new();

    public static void Map<TSource, TDest>(TSource source, TDest dest)
        where TSource : notnull
        where TDest : notnull
    {
        var key = (typeof(TSource), typeof(TDest));

        mappings = _mappingCache.GetOrAdd(key, _ =>
        {
            var sourceProps = typeof(TSource)
                .GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Where(p => p.CanRead)
                .ToDictionary(p => p.Name);

            var destProps = typeof(TDest)
                .GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Where(p => p.CanWrite)
                .ToDictionary(p => p.Name);

            return sourceProps.Keys
                .Intersect(destProps.Keys)
                .Where(name => sourceProps[name].PropertyType
                    .IsAssignableTo(destProps[name].PropertyType))
                .Select(name => (sourceProps[name], destProps[name]))
                .ToArray();
        });

        foreach (var (srcProp, destProp) in mappings)
        {
            destProp.SetValue(dest, srcProp.GetValue(source));
        }
    }
}

The mapping pairs are computed once per type pair and cached. Repeated calls pay only the GetValue/SetValue cost, not the metadata discovery cost.


FAQ

What is the difference between typeof and GetType() in C# reflection?

typeof is a compile-time operator that resolves to a System.Type at compilation. GetType() is an instance method on object that resolves to the runtime type of the actual object -- which matters for polymorphism. If Animal animal = new Dog(), then typeof(Animal) gives you Animal's type, while animal.GetType() gives you Dog's type. Use typeof when you know the type at compile time; use GetType() when you need the concrete runtime type of an instance.

Is reflection slow in .NET 10?

Reflection carries real overhead compared to compiled code -- metadata lookups, boxing of value types, and the inability to inline or optimize. But the .NET runtime has improved reflection performance significantly across versions. The biggest cost is typically repeated metadata discovery. Caching PropertyInfo and MethodInfo instances -- ideally in a FrozenDictionary for read-heavy scenarios -- eliminates most of that overhead. For absolute peak throughput, compile delegates from expression trees or switch to source generators.

Can I use reflection with AOT compilation?

This is a critical consideration for .NET NativeAOT. Reflection relies on metadata that the AOT compiler may trim if it determines certain types or members are unused. You need to annotate your code with [DynamicallyAccessedMembers] attributes to tell the trimmer what metadata to preserve, or use rd.xml root descriptor files. Alternatively, source generators and [UnsafeAccessor] are fully AOT-compatible and are increasingly the preferred choice in trimming scenarios.

When should I use Activator.CreateInstance vs ConstructorInfo.Invoke?

Activator.CreateInstance is the simpler API for one-off or infrequent instantiation -- it handles the common case with minimal ceremony. ConstructorInfo.Invoke is better when you need fine-grained control over constructor selection (e.g., picking a specific overload by parameter types) or when you're building a cached fast path. For high-frequency instantiation, consider caching a compiled delegate built from the ConstructorInfo rather than calling Invoke in a loop.

What are the security risks of C# reflection?

Reflection can bypass access modifiers (private, protected) when using BindingFlags.NonPublic, which breaks encapsulation. In .NET 5+, partial trust and code access security (CAS) are not supported. Reflection's security concern today is primarily around bypassing encapsulation and accessing private state -- use it only in controlled infrastructure code. When loading assemblies dynamically (e.g., plugins), you're executing code from potentially untrusted sources -- validate plugin signatures, load them in isolated AssemblyLoadContext instances, and consider running them in separate processes for hard isolation. Never load assemblies from paths that untrusted users can influence.

What are the best alternatives to reflection for performance-critical code?

Four alternatives cover most scenarios. First, expression trees: compile a Func<T, TResult> from a LambdaExpression once and invoke it at near-native speed. Second, source generators: emit the equivalent code at compile time and pay zero runtime overhead. Third, [UnsafeAccessor] (.NET 8+): access private members with native performance and full AOT compatibility. Fourth, interface-based design: often the reflection is there because the abstraction layer is missing -- adding an interface eliminates the need for reflection entirely. For DI registration specifically, reviewing Automatic Dependency Injection in C#: The Complete Guide to Needlr shows how source generation can replace runtime assembly scanning.


Summary

C# reflection is a powerful runtime inspection and invocation API with real trade-offs. The core types -- Type, Assembly, PropertyInfo, MethodInfo, ConstructorInfo -- give you access to metadata and runtime behavior that simply isn't possible with static code alone. Plugin systems, serialization, DI containers, and test frameworks all depend on it.

But reflection comes with costs: runtime overhead, AOT incompatibility without careful annotation, and the risk of bypassing intentional encapsulation. In .NET 10, those costs are lower than ever -- and tools like FrozenDictionary for caching, expression trees for compiled delegates, and source generators for compile-time code generation give you escape hatches when reflection's overhead isn't acceptable.

The right mental model: reach for reflection when types are truly unknown at compile time. When you know the type, use the type directly. When you need dynamic behavior, profile first -- then decide whether caching, delegates, or source generation gets you the performance profile you need.

C# Source Generators vs Reflection: Which Should You Use?

Compare C# source generators vs reflection in .NET 10. Understand performance differences, AOT compatibility, and when to choose source generators over runtime reflection.

How to Master the Art of Reflection in CSharp and Boost Your Programming Skills

Learn about reflection in CSharp and how it can be used. See how reflection in C# allows you to explore and modify objects, classes, and assemblies at runtime.

Reflection in C#: 4 Simple But Powerful Code Examples

Reflection in C# is powerful, but with great power comes great responsibility. Check out these 4 quick examples of reflection in C# to see it in action!

An error has occurred. This application may no longer respond until reloaded. Reload