Leveraging Reflection for Dynamic Type Inspection in .NET Core

Understanding Reflection

Reflection lets you examine types, read metadata, and invoke members dynamically at runtime. You'll use reflection for dependency injection containers, serialization libraries, plugin systems, and tools that work with unknown types. It's powerful but comes with performance costs and complexity.

The Type class is the entry point to reflection. You get Type objects through typeof() for compile-time known types or GetType() on instances. Once you have a Type, you can discover its properties, methods, fields, and attributes.

You'll learn how to inspect types, invoke methods dynamically, access properties, and understand when reflection is worth the trade-offs versus alternatives like source generators or strongly-typed approaches.

Inspecting Types with GetType and typeof

The typeof operator returns a Type object for a compile-time known type. It's resolved at compile time and has minimal runtime cost. GetType() is an instance method that returns the actual runtime type of an object, handling polymorphism and derived types.

Once you have a Type object, you can query its properties, methods, constructors, and attributes. This metadata lets you understand object structure without hardcoding knowledge about specific types. Frameworks use this extensively for automatic configuration.

Here's how to inspect types and discover their members:

Program.cs - Type inspection
using System;
using System.Reflection;

var person = new Person { Id = 1, Name = "John Doe", Age = 30, Email = "john@example.com" };

// Get type at runtime
Type personType = person.GetType();
Console.WriteLine($"Type: {personType.Name}");
Console.WriteLine($"Namespace: {personType.Namespace}");
Console.WriteLine($"Full Name: {personType.FullName}");

// Using typeof for compile-time known types
Type knownType = typeof(Person);
Console.WriteLine($"\nSame type? {personType == knownType}");

// Discover properties
Console.WriteLine("\nProperties:");
PropertyInfo[] properties = personType.GetProperties();
foreach (var prop in properties)
{
    var value = prop.GetValue(person);
    Console.WriteLine($"  {prop.Name} ({prop.PropertyType.Name}): {value}");
}

// Discover methods
Console.WriteLine("\nPublic Methods:");
MethodInfo[] methods = personType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
foreach (var method in methods)
{
    Console.WriteLine($"  {method.Name}");
}

// Check for attributes
var attributes = personType.GetCustomAttributes();
Console.WriteLine($"\nAttributes: {attributes.Count()}");

// Type checking
Console.WriteLine($"\nIs class? {personType.IsClass}");
Console.WriteLine($"Is value type? {personType.IsValueType}");
Console.WriteLine($"Is public? {personType.IsPublic}");

class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }
    public string Email { get; set; }

    public string GetDisplayName()
    {
        return $"{Name} (ID: {Id})";
    }
}

GetProperties() and GetMethods() return arrays of metadata objects describing each member. BindingFlags control which members you see - public versus private, instance versus static, declared versus inherited. You can filter to exactly what you need.

⚠️ Native AOT and Trimming Considerations

Reflection doesn't work well with Native AOT compilation or aggressive trimming. The AOT compiler needs to know which types and members you'll access at compile time. Dynamic type discovery breaks this requirement. If you're targeting Native AOT, use source generators instead of reflection, or annotate your code with DynamicallyAccessedMembers attributes to preserve specific members.

Invoking Methods Dynamically

MethodInfo.Invoke lets you call methods when you don't know them at compile time. You pass the target object and an array of arguments. The runtime handles parameter matching and invokes the method. This enables plugin architectures and generic frameworks.

Dynamic invocation is much slower than direct calls because of parameter validation, boxing, and reflection overhead. For frequently called methods, consider creating delegates with CreateDelegate or using expression trees for better performance.

Here's how to invoke methods and access properties dynamically:

Program.cs - Dynamic invocation
using System;
using System.Reflection;

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

// Invoke method dynamically
MethodInfo addMethod = calcType.GetMethod("Add");
object result = addMethod.Invoke(calculator, new object[] { 10, 20 });
Console.WriteLine($"Add(10, 20) = {result}");

// Invoke method with different parameters
MethodInfo multiplyMethod = calcType.GetMethod("Multiply");
result = multiplyMethod.Invoke(calculator, new object[] { 5, 7 });
Console.WriteLine($"Multiply(5, 7) = {result}");

// Get and set properties dynamically
var product = new Product { Id = 1, Name = "Laptop", Price = 999.99m };
Type productType = typeof(Product);

PropertyInfo nameProperty = productType.GetProperty("Name");
string name = (string)nameProperty.GetValue(product);
Console.WriteLine($"\nOriginal name: {name}");

nameProperty.SetValue(product, "Gaming Laptop");
Console.WriteLine($"Updated name: {product.Name}");

// Invoke private methods (for testing scenarios)
MethodInfo privateMethod = calcType.GetMethod("ValidateInput",
    BindingFlags.NonPublic | BindingFlags.Instance);
bool isValid = (bool)privateMethod.Invoke(calculator, new object[] { 5 });
Console.WriteLine($"\nValidateInput(5): {isValid}");

class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }

    public int Multiply(int a, int b)
    {
        return a * b;
    }

    private bool ValidateInput(int value)
    {
        return value >= 0;
    }
}

class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

The Invoke method returns object, so you need to cast results to the correct type. Arguments must match parameter types exactly, or you'll get runtime exceptions. For better type safety, validate parameters before invocation or use try-catch blocks.

Reading Custom Attributes

Attributes add metadata to your code that reflection can read at runtime. You use attributes for configuration, validation, serialization hints, and framework integration. Custom attributes let you create your own metadata system.

GetCustomAttributes returns all attributes on a type or member. You can filter by attribute type to find specific metadata. This pattern enables declarative programming where attributes drive behavior without explicit code.

Here's how to define and read custom attributes:

Program.cs - Custom attributes
using System;
using System.Reflection;

// Define custom attribute
[AttributeUsage(AttributeTargets.Property)]
public class ValidationAttribute : Attribute
{
    public int MinLength { get; set; }
    public int MaxLength { get; set; }
    public bool Required { get; set; }
}

class User
{
    [Validation(MinLength = 3, MaxLength = 50, Required = true)]
    public string Username { get; set; }

    [Validation(MinLength = 8, MaxLength = 100, Required = true)]
    public string Password { get; set; }

    public string Email { get; set; }
}

// Validate using reflection
var user = new User { Username = "ab", Password = "short" };
Type userType = typeof(User);

Console.WriteLine("Validation Results:");
foreach (var property in userType.GetProperties())
{
    var validationAttr = property.GetCustomAttribute<ValidationAttribute>();
    if (validationAttr == null) continue;

    var value = (string)property.GetValue(user);
    Console.WriteLine($"\n{property.Name}:");

    if (validationAttr.Required && string.IsNullOrEmpty(value))
    {
        Console.WriteLine("  ✗ Required field is empty");
        continue;
    }

    if (value?.Length < validationAttr.MinLength)
        Console.WriteLine($"  ✗ Too short (min: {validationAttr.MinLength})");

    if (value?.Length > validationAttr.MaxLength)
        Console.WriteLine($"  ✗ Too long (max: {validationAttr.MaxLength})");

    if (value?.Length >= validationAttr.MinLength && value?.Length <= validationAttr.MaxLength)
        Console.WriteLine("  ✓ Valid");
}

Custom attributes combine well with reflection for building frameworks. Validation libraries, serializers, and ORMs all use this pattern. The attribute provides the what, while reflection provides the how to act on that metadata.

Improving Reflection Performance

Reflection is slow compared to direct method calls. GetType and typeof are relatively fast, but MethodInfo.Invoke, property access, and object creation through reflection add significant overhead. For hot paths, you need optimization strategies.

Caching Type and MemberInfo objects eliminates repeated lookups. Creating delegates through CreateDelegate converts reflection calls into fast delegate invocations. For even better performance, use expression trees to generate compiled methods.

Here's how to optimize reflection-heavy code:

Program.cs - Performance optimization
using System;
using System.Diagnostics;
using System.Reflection;

var service = new DataService();
int iterations = 1000000;

// Slow: Direct reflection invocation
var sw = Stopwatch.StartNew();
MethodInfo processMethod = typeof(DataService).GetMethod("ProcessData");
for (int i = 0; i < iterations; i++)
{
    processMethod.Invoke(service, new object[] { i });
}
sw.Stop();
Console.WriteLine($"Reflection invoke: {sw.ElapsedMilliseconds}ms");

// Fast: Cached delegate
sw.Restart();
var processDelegate = (Action<int>)Delegate.CreateDelegate(
    typeof(Action<int>),
    service,
    processMethod);

for (int i = 0; i < iterations; i++)
{
    processDelegate(i);
}
sw.Stop();
Console.WriteLine($"Delegate invoke: {sw.ElapsedMilliseconds}ms");

// Fastest: Direct call (baseline)
sw.Restart();
for (int i = 0; i < iterations; i++)
{
    service.ProcessData(i);
}
sw.Stop();
Console.WriteLine($"Direct call: {sw.ElapsedMilliseconds}ms");

class DataService
{
    private int _sum = 0;

    public void ProcessData(int value)
    {
        _sum += value;
    }

    public int GetSum() => _sum;
}

CreateDelegate typically runs 10-100x faster than MethodInfo.Invoke while still providing dynamic behavior. Cache delegates in static fields or dictionaries for repeated use. This gives you most of reflection's flexibility with near-native performance.

Try It Yourself

This complete example demonstrates a simple plugin system using reflection to discover and invoke plugin methods dynamically.

Program.cs - Plugin system with reflection
using System;
using System.Collections.Generic;
using System.Reflection;

// Plugin interface
public interface IPlugin
{
    string Name { get; }
    void Execute();
}

// Plugin implementations
public class LoggingPlugin : IPlugin
{
    public string Name => "Logging Plugin";
    public void Execute() => Console.WriteLine("[LOG] Plugin executed");
}

public class MetricsPlugin : IPlugin
{
    public string Name => "Metrics Plugin";
    public void Execute() => Console.WriteLine("[METRICS] Tracking execution");
}

// Plugin manager using reflection
public class PluginManager
{
    private readonly List<IPlugin> _plugins = new();

    public void DiscoverPlugins()
    {
        // Get all types in current assembly
        Assembly assembly = Assembly.GetExecutingAssembly();
        Type pluginInterface = typeof(IPlugin);

        foreach (Type type in assembly.GetTypes())
        {
            // Check if type implements IPlugin
            if (pluginInterface.IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract)
            {
                // Create instance using reflection
                IPlugin plugin = (IPlugin)Activator.CreateInstance(type);
                _plugins.Add(plugin);
                Console.WriteLine($"Discovered: {plugin.Name}");
            }
        }
    }

    public void ExecuteAll()
    {
        Console.WriteLine("\nExecuting plugins:");
        foreach (var plugin in _plugins)
        {
            plugin.Execute();
        }
    }

    public void InspectPlugin(string pluginName)
    {
        var plugin = _plugins.Find(p => p.Name == pluginName);
        if (plugin == null)
        {
            Console.WriteLine($"\nPlugin '{pluginName}' not found");
            return;
        }

        Type type = plugin.GetType();
        Console.WriteLine($"\n=== Plugin Details: {pluginName} ===");
        Console.WriteLine($"Type: {type.FullName}");

        Console.WriteLine("\nProperties:");
        foreach (var prop in type.GetProperties())
        {
            Console.WriteLine($"  {prop.Name}: {prop.PropertyType.Name}");
        }

        Console.WriteLine("\nMethods:");
        foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
        {
            Console.WriteLine($"  {method.Name}()");
        }
    }
}

// Main program
var manager = new PluginManager();
manager.DiscoverPlugins();
manager.ExecuteAll();
manager.InspectPlugin("Logging Plugin");
ReflectionDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

Running the example:

  1. Create a new folder and save both files
  2. Run dotnet run to see plugin discovery
  3. Try adding your own plugin class implementing IPlugin
  4. Watch how the system discovers it automatically
  5. Experiment with inspecting different plugins

Common Pitfalls & How to Avoid Them

Performance degradation happens when you use reflection in tight loops. Reflection is expensive, and repeated calls compound the cost. Cache Type and MemberInfo objects, create delegates for repeated invocations, or reconsider whether you need reflection at all. Profile before optimizing.

Null reference exceptions occur when GetMethod or GetProperty returns null for non-existent members. Always check for null before invoking. Use BindingFlags explicitly to avoid confusion about which members you're searching for. Case sensitivity matters for member names.

Security risks emerge with reflection in untrusted environments. Reflection can access private members and bypass access modifiers. If you're loading external assemblies or accepting user input, validate types and members carefully. Consider sandboxing or restricted reflection contexts.

AOT and trimming incompatibility breaks applications using dynamic reflection. The compiler can't preserve types it doesn't see referenced statically. Use DynamicallyAccessedMembers attributes to preserve specific members, or migrate to source generators for AOT-compatible code generation.

Type confusion causes runtime errors when casting reflection results. Invoke returns object, and GetValue returns object. You need to cast to the correct type, which can fail at runtime. Use pattern matching or as operator with null checks for safer casting.

Frequently Asked Questions (FAQ)

When should I use GetType() versus typeof()?

Use typeof() when you know the type at compile time. It's faster and evaluated at compile time. Use GetType() when you need the runtime type of an object instance. For performance-critical code, prefer typeof() whenever possible since it doesn't require any runtime work.

How does reflection affect performance?

Reflection is significantly slower than direct method calls, often 10-100x slower depending on the operation. GetType() and typeof() are relatively fast, but MethodInfo.Invoke and property access through reflection add substantial overhead. Cache reflection results and consider CreateDelegate for repeated invocations.

Does reflection work with Native AOT compilation?

Reflection is limited in Native AOT scenarios because the AOT compiler needs to know all types at compile time. Dynamic reflection that discovers types at runtime won't work. Use source generators instead, or annotate code with DynamicallyAccessedMembers attributes to preserve specific members for reflection.

Back to Articles