Attributes and Reflection

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Every .NET assembly carries a complete description of itself: its types, their members, their signatures, and any attributes attached to them. Attributes are how you add declarative information to that metadata; reflection is how you read it back and act on it at run time. Together they are what makes serializers, dependency-injection containers, test runners, ORMs and validation frameworks possible without any of them knowing your types in advance.

The modern counterweight is at the end of this page: reflection is flexible but slow, and it defeats trimming and ahead-of-time compilation. Source generators do the same jobs at compile time.

Applying Attributes

An attribute is written in square brackets immediately before its target. It is a class deriving from System.Attribute; the brackets are a constructor call, so positional arguments are constructor parameters and Name = value arguments set public properties or fields:

using System.ComponentModel;

[Description("A point in two dimensions")]
public sealed class Point
{
    [Description("The horizontal coordinate")]
    public double X { get; init; }

    public double Y { get; init; }

    [Obsolete("Use the Distance property instead.", error: false)]
    public double Length() => Math.Sqrt(X * X + Y * Y);
}

Several attributes may be applied to one target, each in its own brackets or comma-separated inside one pair. Arguments must be compile-time constants — a constant expression, a typeof(…​), or an array of those.

Targets

Most attributes attach to the declaration that follows them. Where that is ambiguous — a property’s backing field versus the property, a method’s return value versus the method — an explicit target prefix disambiguates:

using System.Diagnostics.CodeAnalysis;

[assembly: System.Reflection.AssemblyMetadata("build", "ci")]
[module: SkipLocalsInit]

public sealed class Targeted
{
    [field: NonSerialized]                  // the compiler-generated backing field
    public string? Name { get; set; }

    [return: MaybeNull]                     // the return value, not the method
    public string Find(string key) => key;

    public void Configure([AllowNull] string value) => _ = value;
}

The available targets are assembly, module, field, event, method, param, property, return and type. Assembly- and module-level attributes must appear after any using directives and outside every type declaration.

Generic Attributes

Since C# 11, an attribute class may be generic, which removes the typeof indirection that older APIs needed:

public sealed class ValidatedByAttribute<TValidator> : Attribute
    where TValidator : class
{
}

public sealed class OrderValidator
{
}

[ValidatedBy<OrderValidator>]
public sealed class Order
{
    public int Quantity { get; init; }
}

The type argument must be fully closed — [ValidatedBy<T>] inside a generic type is not allowed.

Attributes the Compiler Understands

A handful of attributes change compilation rather than merely recording metadata.

[Obsolete]

public static class Api
{
    [Obsolete("Use ParseExact instead.")]
    public static int Parse(string text) => int.Parse(text);

    [Obsolete("Removed in 3.0.", error: true, DiagnosticId = "LIB0001", UrlFormat = "https://example.com/{0}")]
    public static int Legacy(string text) => int.Parse(text);

    public static int ParseExact(string text) => int.Parse(text);
}

error: true turns use into a compile error. DiagnosticId gives the warning a stable id so consumers can suppress exactly this one.

[Conditional]

A call to a method marked [Conditional("SYMBOL")] is omitted entirely — along with its argument expressions — unless the symbol is defined in the calling code. This is how Debug.Assert disappears from release builds:

using System.Diagnostics;

public static class Tracing
{
    [Conditional("TRACE_DETAIL")]
    public static void Detail(string message) => Console.WriteLine(message);
}

public static class TracingUser
{
    public static void Run()
    {
        // Compiled away unless TRACE_DETAIL is defined; ExpensiveDescribe() is not even called.
        Tracing.Detail(ExpensiveDescribe());
    }

    private static string ExpensiveDescribe() => "detail";
}

The method must return void, and the removal happens at the call site, based on the symbols defined where the call is compiled. See Preprocessor Directives and Compilation.

Caller-Information Attributes

The compiler fills these parameters in at the call site, at no run-time cost:

using System.Runtime.CompilerServices;

public static class Guard
{
    public static void Require(
        bool condition,
        [CallerArgumentExpression(nameof(condition))] string? expression = null,
        [CallerMemberName] string? member = null,
        [CallerFilePath] string? file = null,
        [CallerLineNumber] int line = 0)
    {
        if (!condition)
        {
            throw new InvalidOperationException(
                $"'{expression}' failed in {member} at {System.IO.Path.GetFileName(file)}:{line}");
        }
    }
}

public static class GuardUser
{
    public static void Run(int count)
    {
        // Message reads: 'count > 0' failed in Run at GuardUser.cs:NN
        Guard.Require(count > 0);
    }
}

[CallerArgumentExpression] (C# 10) is what gives ArgumentNullException.ThrowIfNull(value) its accurate parameter name without the caller repeating it.

[ModuleInitializer]

A static void method with no parameters, marked [ModuleInitializer], runs once before any other code in the assembly. It is intended for library setup that must happen before first use — registration, feature switches — not for general initialization:

using System.Runtime.CompilerServices;

internal static class Startup
{
    [ModuleInitializer]
    internal static void Initialize() => Registry.Register("format", typeof(string));
}

internal static class Registry
{
    private static readonly Dictionary<string, Type> Handlers = new();

    internal static void Register(string key, Type handler) => Handlers[key] = handler;
}

The method must be static, parameterless, return void, and be accessible from the module (so not private inside a private type).

[SkipLocalsInit] and [InlineArray]

[SkipLocalsInit] suppresses the CLR’s zero-initialization of a method’s locals — a measurable win for methods with large stackalloc buffers, and unsafe if you then read an uninitialized local. It requires <AllowUnsafeBlocks>.

[InlineArray] (C# 12) turns a struct with a single field into a fixed-size inline buffer usable as a span, without unsafe:

using System.Runtime.CompilerServices;

[InlineArray(8)]
public struct Digits
{
    private int _element0;      // exactly one instance field
}

public static class InlineArrayUsage
{
    public static int Sum()
    {
        Digits digits = default;

        for (int i = 0; i < 8; i++)
        {
            digits[i] = i;                  // indexable
        }

        int total = 0;
        foreach (int value in digits)       // enumerable, and convertible to Span<int>
        {
            total += value;
        }

        return total;
    }
}

Nullable-Analysis Attributes

These do not change code generation; they teach the compiler’s flow analysis about contracts it cannot infer:

using System.Diagnostics.CodeAnalysis;

public sealed class Store
{
    private readonly Dictionary<string, string> _items = new();

    // On `true`, `value` is definitely not null.
    public bool TryGet(string key, [NotNullWhen(true)] out string? value) =>
        _items.TryGetValue(key, out value);

    // Returns null only if the argument was null.
    [return: NotNullIfNotNull(nameof(fallback))]
    public string? GetOrDefault(string key, string? fallback) =>
        _items.GetValueOrDefault(key) ?? fallback;

    // The argument may be null even though the parameter type is not nullable.
    public void Log([AllowNull] string message) => Console.WriteLine(message ?? "(none)");

    // After this returns, `_cache` is guaranteed non-null.
    [MemberNotNull(nameof(_cache))]
    private void EnsureCache() => _cache ??= new List<string>();

    private List<string>? _cache;

    // This method never returns normally.
    [DoesNotReturn]
    private static void Fail(string message) => throw new InvalidOperationException(message);
}

Writing a Custom Attribute

An attribute class derives from Attribute, is conventionally named …Attribute (the suffix is dropped at the use site), is usually sealed, and declares where it may be applied with [AttributeUsage]:

[AttributeUsage(
    AttributeTargets.Class | AttributeTargets.Struct,
    AllowMultiple = true,
    Inherited = false)]
public sealed class TableAttribute : Attribute
{
    public TableAttribute(string name) => Name = name;      // positional

    public string Name { get; }

    public string? Schema { get; init; }                    // named

    public int Version { get; init; } = 1;
}

[Table("orders", Schema = "sales", Version = 2)]
public sealed class OrderRow
{
    public int Id { get; init; }
}

AllowMultiple permits repeating the attribute on one target; Inherited controls whether GetCustomAttributes reports it on derived types. Attribute constructor and property types are restricted to the constant-expressible set: the simple types, string, enum, Type, object, and single-dimensional arrays of those.

Reading Attributes with Reflection

public static class TableReader
{
    public static string Describe<T>()
    {
        Type type = typeof(T);

        TableAttribute? table = type.GetCustomAttribute<TableAttribute>(inherit: false);

        if (table is null)
        {
            return $"{type.Name}: not a table";
        }

        string columns = string.Join(
            ", ",
            type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Select(p => $"{p.Name}:{p.PropertyType.Name}"));

        return $"{table.Schema}.{table.Name} v{table.Version} ({columns})";
    }
}

Attribute.IsDefined is the cheap "is it there at all?" check, since it does not have to construct the attribute instance. CustomAttributeData.GetCustomAttributes reads the raw metadata — constructor arguments and named arguments as data — without instantiating anything, which is what analyzers and reflection-only loading use.

The Reflection API

Type is the entry point. There are two ways to get one, and the difference matters:

public static class TypeAccess
{
    public static void Show(object value)
    {
        Type staticType = typeof(string);        // compile time, from a type name
        Type runtimeType = value.GetType();      // run time, from an instance's actual type

        Console.WriteLine(staticType.FullName);
        Console.WriteLine(runtimeType.FullName);

        // A nullable value type reports its underlying type here.
        Console.WriteLine(Nullable.GetUnderlyingType(typeof(int?))?.Name);
    }
}

typeof(T) is resolved by the compiler and costs nothing; GetType() is a virtual call returning the most derived type of the instance.

The member model hangs off Type: MemberInfo is the base, with MethodInfo, PropertyInfo, FieldInfo, ConstructorInfo and EventInfo beneath it. BindingFlags selects what to look at:

public static class MemberWalk
{
    public static IEnumerable<string> Members(Type type) =>
        type.GetMembers(BindingFlags.Public | BindingFlags.NonPublic |
                        BindingFlags.Instance | BindingFlags.Static |
                        BindingFlags.DeclaredOnly)
            .Select(m => $"{m.MemberType} {m.Name}");
}

Omitting BindingFlags entirely means Public | Instance | Static; DeclaredOnly excludes inherited members.

Invoking Members

public sealed class Calculator
{
    public Calculator()
    {
    }

    public Calculator(int seed) => Total = seed;

    public int Total { get; private set; }

    public int Add(int value) => Total += value;

    private int Secret() => 42;
}

public static class DynamicInvocation
{
    public static void Run()
    {
        Type type = typeof(Calculator);

        // Construction.
        object? instance = Activator.CreateInstance(type);
        object? seeded = Activator.CreateInstance(type, args: new object[] { 10 });

        // A public method.
        MethodInfo add = type.GetMethod(nameof(Calculator.Add))!;
        object? result = add.Invoke(instance, new object[] { 5 });
        Console.WriteLine(result);          // 5

        // A property with a private setter.
        PropertyInfo total = type.GetProperty(nameof(Calculator.Total))!;
        Console.WriteLine(total.GetValue(seeded));

        // A private method.
        MethodInfo secret = type.GetMethod("Secret", BindingFlags.NonPublic | BindingFlags.Instance)!;
        Console.WriteLine(secret.Invoke(instance, parameters: null));
    }
}

An exception thrown by the invoked method arrives wrapped in a TargetInvocationException; its InnerException is the real one.

Generics and Reflection

An open generic type must be closed before it can be used:

public static class GenericReflection
{
    public static object BuildList(Type elementType)
    {
        Type open = typeof(List<>);                         // List`1, unbound
        Type closed = open.MakeGenericType(elementType);    // List<elementType>

        return Activator.CreateInstance(closed)!;
    }

    public static object? CallGeneric(object value)
    {
        MethodInfo open = typeof(GenericReflection).GetMethod(nameof(Identity))!;
        MethodInfo closed = open.MakeGenericMethod(value.GetType());

        return closed.Invoke(null, new[] { value });
    }

    public static T Identity<T>(T value) => value;
}

Type.IsGenericTypeDefinition distinguishes List<> from List<int>; GetGenericArguments returns the type arguments (or the parameters, for an open type).

Faster Than Invoke

MethodInfo.Invoke costs roughly a microsecond per call — fine once, ruinous in a loop. When the same member is called repeatedly, convert it into a delegate once:

public static class FastInvocation
{
    public static Func<Calculator, int, int> BindAdd()
    {
        MethodInfo add = typeof(Calculator).GetMethod(nameof(Calculator.Add))!;

        // A real delegate: subsequent calls run at normal speed.
        return add.CreateDelegate<Func<Calculator, int, int>>();
    }

    public static Func<Calculator, int> BindTotalGetter()
    {
        PropertyInfo total = typeof(Calculator).GetProperty(nameof(Calculator.Total))!;
        return total.GetGetMethod()!.CreateDelegate<Func<Calculator, int>>();
    }
}

Expression.Lambda(…​).Compile() is the other route, and the one that works when the shape is not known until run time — see Expression Trees and Dynamic. Both produce code that runs at ordinary speed; both pay a one-off cost you should amortise in a cache.

Assemblies

public static class AssemblyBasics
{
    public static void Inspect()
    {
        Assembly current = Assembly.GetExecutingAssembly();

        Console.WriteLine(current.FullName);
        Console.WriteLine(current.GetName().Version);
        Console.WriteLine(current.Location);

        foreach (Type type in current.GetExportedTypes().Take(5))
        {
            Console.WriteLine(type.FullName);
        }

        // Embedded resources travel inside the assembly.
        foreach (string name in current.GetManifestResourceNames())
        {
            Console.WriteLine(name);
        }
    }
}

Loading assemblies dynamically (Assembly.LoadFrom, or an AssemblyLoadContext for a plugin model that can be unloaded) is how plugin systems work. Two cautions: an AssemblyLoadContext is only collectible if nothing from it stays reachable, and loading code from an untrusted location is a security decision, not a technical one.

Reflection.Emit, Briefly

System.Reflection.Emit writes IL at run time — AssemblyBuilder, TypeBuilder, ILGenerator, DynamicMethod. It is what mocking frameworks and some serializers use to generate proxies. It is powerful, hard to debug, and completely unavailable under Native AOT. Unless you are writing that kind of framework, expression trees or a source generator will serve better.

The Cost: Trimming and AOT

Reflection has three problems in modern .NET:

  1. Speed. Member lookup and Invoke are orders of magnitude slower than a direct call. Cache MethodInfo/PropertyInfo objects and convert hot ones into delegates.

  2. Trimming. The trimmer removes code nothing appears to reference. A type only ever reached through Type.GetType("Some.Name") looks unreferenced and is removed — the failure shows up at run time, not build time.

  3. Native AOT. Ahead-of-time compilation has no JIT, so anything requiring code generation — MakeGenericType over value types that were not compiled in, Reflection.Emit, Expression.Compile — can fail at run time.

The compiler and tooling help you find this, if you annotate honestly:

using System.Diagnostics.CodeAnalysis;

public static class TrimAware
{
    // Tells the trimmer to keep this type's public constructors and properties.
    public static object Create(
        [DynamicallyAccessedMembers(
            DynamicallyAccessedMemberTypes.PublicConstructors |
            DynamicallyAccessedMemberTypes.PublicProperties)] Type type) =>
        Activator.CreateInstance(type)!;

    // Tells callers this API is not trim-safe, and produces a warning at their call site.
    [RequiresUnreferencedCode("Scans all loaded assemblies for handlers.")]
    public static IEnumerable<Type> DiscoverHandlers() =>
        AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes());

    [RequiresDynamicCode("Builds a closed generic type at run time.")]
    public static object BuildList(Type element) =>
        Activator.CreateInstance(typeof(List<>).MakeGenericType(element))!;
}

Enabling <IsTrimmable>true</IsTrimmable>, <PublishTrimmed> or <PublishAot> in a project turns these analyses on and surfaces the warnings at build time. See Namespaces, Assemblies and Projects.

The Modern Alternative: Source Generators

A source generator is a Roslyn component that runs during compilation, inspects the code being compiled, and adds more source to it. The result is ordinary compiled code: fast, trim-safe, AOT-safe, and debuggable. Where a framework once used reflection, it now typically generates:

  • System.Text.Json — JsonSerializerContext generates serializers instead of reflecting over properties.

  • LoggerMessage — generates strongly-typed, allocation-free logging methods.

  • [LibraryImport] — generates P/Invoke marshalling code instead of emitting it at run time.

  • [GeneratedRegex] — compiles a regular expression into a method at build time.

using System.Text.RegularExpressions;

public static partial class Validation
{
    // The body is generated at compile time; no run-time regex compilation.
    [GeneratedRegex(@"^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$", RegexOptions.IgnoreCase)]
    private static partial Regex EmailRegex();

    public static bool IsEmail(string candidate) => EmailRegex().IsMatch(candidate);
}

Note the shape that makes this work: partial members whose implementation the generator supplies — extended in C# 13 to partial properties and in C# 14 to partial events and constructors.

Interceptors are the newer, related feature: a generator can mark a method as intercepting a specific call site, replacing that call with its own — how, for example, a compiled-query framework can rewrite a particular invocation without changing the source. They remain an advanced, opt-in feature (<InterceptorsNamespaces>), intended for generator authors rather than application code.

Practical Guidance

  • Use attributes to describe declarative facts about code; keep behaviour out of them.

  • Seal attribute classes, set [AttributeUsage] explicitly, and prefer named properties for optional data.

  • Cache every Type, MethodInfo and PropertyInfo you look up; convert hot members into delegates.

  • Prefer nameof over string literals wherever a member name is needed.

  • Before reaching for reflection, check whether a source generator already solves the problem — especially in a library that must support trimming or Native AOT.

  • If a public API does need reflection, annotate it with [RequiresUnreferencedCode] / [DynamicallyAccessedMembers] so consumers find out at build time rather than in production.

See Also