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:
-
Speed. Member lookup and
Invokeare orders of magnitude slower than a direct call. CacheMethodInfo/PropertyInfoobjects and convert hot ones into delegates. -
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. -
Native AOT. Ahead-of-time compilation has no JIT, so anything requiring code generation —
MakeGenericTypeover 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—JsonSerializerContextgenerates 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,MethodInfoandPropertyInfoyou look up; convert hot members into delegates. -
Prefer
nameofover 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
-
Expression Trees and Dynamic — building and compiling code at run time.
-
Generics — the type model reflection exposes.
-
Nullable Types and Null Safety — the nullable-analysis attributes in context.
-
Namespaces, Assemblies and Projects — assemblies, trimming and Native AOT.
-
Preprocessor Directives and Compilation — the symbols
[Conditional]depends on.