AlgoMaster Logo

Reflection Basics

Medium Priority25 min readUpdated June 6, 2026

Reflection is the ability of a .NET program to look at its own types at runtime: which classes exist, what methods and fields they have, what attributes are attached to them. The machinery lives in the System.Reflection namespace and is the foundation of serializers, ORMs, DI containers, test runners, and most build-time tooling. This lesson covers the entry points (how to get a Type in the first place), the inspection API surface (Type properties, Assembly, the MemberInfo hierarchy, BindingFlags), and the performance cost you pay for using any of it. The _Inspecting Types at Runtime_ lesson takes these tools and actually invokes methods, reads field values, and constructs objects at runtime.

What Reflection Is

Compiled C# is not a black box. When the compiler emits an assembly (a .dll or .exe), it embeds metadata alongside the IL: a structured description of every type, method, parameter, attribute, and field the assembly contains. That metadata is part of the assembly file itself. The runtime reads it to load types, the debugger reads it to map IL back to source, and your code can read it through reflection.

The System.Reflection namespace is the public API over that metadata. You ask it questions like "what types are in this assembly?", "what public properties does this class have?", "is this method generic?", and it answers based on what the compiler wrote into the file. The answers are read-only descriptions; reflection by itself does not change anything. The _Inspecting Types at Runtime_ lesson introduces the APIs that use those descriptions to invoke methods, read field values, and construct instances.

That whole program never references the Product class as anything other than typeof(Product). The three property names and their types come out of the metadata that the compiler put into the assembly, not out of code that knows about Product. That distinction is the whole point of reflection: the code that's doing the inspecting does not need to know what it's inspecting at compile time.

The System.Reflection namespace is the only namespace you typically need to import. A handful of related APIs (Assembly.Load, the Activator class for creating instances, Type.GetType itself) live in System and are already imported through the implicit usings you get in a .NET 8 console project.

Three Ways to Get a Type

Almost every reflection operation starts with a Type object. There are three ways to get one, and which one is right depends on what you know at compile time.

The first way is typeof(T), where T is the type name written directly in your source code. This is the fastest option, fully resolved at compile time, and the one to use when you know the type at the moment you write the code.

The List1 name is not a typo. The backtick-and-number suffix is how the metadata encodes a generic type's arity (the number of type parameters). List<T> has arity 1, Dictionary<TKey, TValue>` has arity 2, and so on. The runtime uses that suffix internally; the friendly name with angle brackets is something you can compose for display, but reflection itself speaks the encoded form.

The second way is obj.GetType(), an instance method on every object that returns the runtime type of the object. Use this when you have an instance but the variable's declared type might be a base class or interface.

The variable payment is declared as PaymentMethod, so typeof(...) of the variable would give the abstract base. But the object the variable points to is a CreditCard, and GetType() returns that. The difference matters any time you're walking through a collection of base-typed references and want to know what each one actually is.

GetType() is also the only option that works on a dynamic or object you received from somewhere else, where you literally do not have a type name to write down at compile time.

GetType() is a single virtual call into the runtime and returns a cached Type instance. It is fast. The expensive reflection work starts when you ask the returned Type for its members, not when you obtain the Type itself.

The third way is Type.GetType("Namespace.TypeName, AssemblyName"), which looks up a type by its fully qualified name as a string. This is the slow, late-bound option and the only one that works when you don't know the type's identity until runtime: plugin systems, configuration files, factories driven by strings.

Three things to take from this. First, the assembly-qualified form (TypeName, AssemblyName) is what you pass for a type that lives outside the currently executing assembly. For types in mscorlib / System.Private.CoreLib and types in the calling assembly, you can omit the assembly part and the lookup still works. Second, generic types use the backtick form (List\1) not the angle-bracket form. Third, when the lookup fails, GetType returns null instead of throwing. There's an overload that takes a throwOnError` flag if you'd rather get an exception.

Type.GetType(string) parses the name, locates the assembly, loads it if it isn't loaded yet, and walks the metadata to find the type. On a cold call it can be slow enough to matter (milliseconds for a remote-mounted assembly). For hot paths, look the type up once at startup and cache the Type reference.

The three options differ in when the lookup happens and how much it costs.

MethodResolved atCostUse when
typeof(T)Compile timeNear-zero (constant)You know the type name in source
obj.GetType()Runtime, single virtual callCheapYou have an instance, possibly through a base/interface reference
Type.GetType("...")Runtime, parses string and walks metadataExpensive on cold pathThe type identity comes from a string at runtime

Inspecting a Type with Type Properties

Once you have a Type instance, a long list of properties describes the type without you needing to call any inspection methods. These are pure reads off the metadata, and the values are the same for every call.

A few of those values deserve a closer look. Namespace is empty here because Product is declared at the top of the file without a namespace declaration; in a real project the type would have a namespace and FullName would be MyCompany.Catalog.Product or similar. BaseType is System.Object because Product doesn't inherit from anything explicitly, and every class implicitly inherits from object. Assembly returns the Assembly object that contains the type; we'll come back to that.

The boolean properties answer category questions, and there's one for almost every category the language has.

PropertyTrue when
IsClassThe type is a class (also true for delegates)
IsInterfaceThe type is an interface
IsValueTypeThe type is a struct, enum, or primitive
IsEnumThe type is specifically an enum
IsAbstractThe class is abstract or the type is an interface
IsSealedThe class is sealed, or the type is a struct (structs are implicitly sealed)
IsGenericTypeThe type has type parameters, open or closed
IsGenericTypeDefinitionThe type is the open form, like List<> rather than List<int>
IsArrayThe type is an array type like int[] or string[,]
IsPrimitiveThe type is a primitive like int, bool, double
IsPublic / IsNotPublicThe type's visibility

Two of those need extra care. First, IsAbstract is true for any interface, because interfaces are implicitly abstract in the metadata. If you want to distinguish "abstract class" from "interface," check IsInterface first.

Second, IsGenericType is true for both List<> (the open generic, no type argument supplied) and List<int> (the closed generic, with int plugged in). IsGenericTypeDefinition is the property that's only true for the open form. The two are easy to confuse and lead to bugs in code that handles generics.

For arrays, IsArray is the property that matters, and the element type comes from GetElementType().

The pattern here is consistent: Type is a description, and most questions you'd want to ask have a property or a one-line method to answer them. The Microsoft docs list dozens more (IsNested, IsCOMObject, ContainsGenericParameters, and so on), but the table above covers the ones that come up in everyday reflection code.

Assemblies

A Type lives inside an Assembly. An assembly is the .NET unit of deployment: a .dll or .exe file that contains compiled IL, the metadata that describes it, and an embedded manifest with the assembly's name, version, and references. The Assembly class in System.Reflection represents one loaded assembly.

Output (varies by build):

Assembly.GetExecutingAssembly() returns the assembly that contains the code making the call. There are two siblings.

Assembly.GetCallingAssembly() returns the assembly of the immediate caller. If method A in assembly Alpha calls method B in assembly Beta, and B calls GetCallingAssembly(), the result is Alpha. This is occasionally useful inside library code that wants to know who invoked it, but the JIT can inline calls in ways that make the answer fuzzy, so don't build critical logic on it.

Assembly.GetEntryAssembly() returns the assembly of the entry point: the executable that started the process. From a console app, all three calls usually return the same assembly. From a library being called by an app, GetEntryAssembly() returns the app and GetExecutingAssembly() returns the library.

Output (from a top-level program):

To load an assembly by name or path, use Assembly.Load or Assembly.LoadFrom. Load resolves a simple or fully qualified name through the runtime's normal probing rules; LoadFrom takes a file path directly. Either way, the result is an Assembly you can inspect.

Three methods on Assembly come up over and over.

GetTypes() returns every type defined in the assembly: public, internal, nested, abstract, interface, all of them. The return is a flat Type[]. This is how plugin systems enumerate "what's in this DLL?" but it has a real cost.

Output (typical small app):

GetTypes() walks the entire type table of the assembly and may force every type to be loaded into the JIT. For a small console app that's nothing; for a large library (Entity Framework Core, for example, has thousands of types), it allocates a big array and can trigger non-trivial JIT work. Cache the result if you call it more than once.

GetType(string) looks up a single named type inside the assembly. It returns null if the type doesn't exist or if the name is misspelled.

GetCustomAttributes returns every attribute applied to the assembly itself (things like [AssemblyTitle], [AssemblyVersion], [InternalsVisibleTo]). Reflection is what reads attributes back out at runtime.

Output (typical):

Most of those are emitted by the build system. Your own [assembly: ...] attributes show up in the same list.

The MemberInfo Hierarchy

Every member you can declare on a type (method, field, property, event, even nested types) is represented at runtime by a class in the System.Reflection namespace. They all share a common base: MemberInfo. The hierarchy gives reflection a uniform shape: any time you want "all the members of this type," you ask for MemberInfo[] and then check the kind of each one.

MemberInfo is the abstract base. It defines the things every member has: Name, DeclaringType (the type that originally declared the member), ReflectedType (the type you reflected through to get here, which can differ for inherited members), MemberType (an enum value identifying the kind), and GetCustomAttributes(...) for attribute lookups.

MethodBase is the shared base for anything callable: regular methods and constructors. It has parameter info, calling conventions, and the IL body. Two concrete subclasses inherit from it: MethodInfo for regular methods (named, with a return type) and ConstructorInfo for constructors (no name in the language sense, no declared return type, called through new).

FieldInfo represents a field (instance or static). It exposes the field type and the storage so reflection can read or write the value directly, bypassing properties.

PropertyInfo represents a property: a pair of accessor methods backed by an optional field. The PropertyType, CanRead, CanWrite, and the GetMethod / SetMethod (which are themselves MethodInfo instances) live here.

EventInfo represents an event. Events have add and remove accessor methods (also MethodInfo), and a delegate type that describes the handler signature.

A nested Type is a member too. The Type class inherits from MemberInfo, which is why typeof(Outer).GetMembers() can return a nested Inner type in the same array as methods and fields.

The property Id shows up three times: once as the Property itself, and twice as the synthesized accessor methods get_Id and set_Id. The same is true for the event, which contributes add_StatusChanged and remove_StatusChanged. The constructor's name is .ctor, which is the metadata name for an instance constructor (static constructors have the name .cctor). The base object methods (GetType, ToString, Equals, GetHashCode) come through because GetMembers() walks the inheritance chain by default.

Each of these member kinds has its own typed accessor on Type: GetMethods(), GetFields(), GetProperties(), GetEvents(), GetConstructors(), GetNestedTypes(). If you only want one kind, those are cleaner than filtering GetMembers() yourself. The _Inspecting Types at Runtime_ lesson uses each of them to actually do work; this lesson just establishes that they exist and are all rooted in MemberInfo.

BindingFlags

By default, the various Get... methods on Type return only public, instance members declared on the type or any of its base classes (with some exceptions). That default is convenient for the most common case (consumers asking "what can I call?") but it's the wrong default for plenty of reflection use cases: serializers want private fields, framework code wants static initializers, diagnostic tools want everything.

BindingFlags is the flags enum you pass to override the default. The flags fall into three groups.

FlagEffect
BindingFlags.PublicInclude public members
BindingFlags.NonPublicInclude private, protected, internal, protected-internal members
BindingFlags.InstanceInclude instance members (require an object to call)
BindingFlags.StaticInclude static members (no instance needed)
BindingFlags.DeclaredOnlySkip members inherited from base classes
BindingFlags.IgnoreCaseCase-insensitive name matching for lookups
BindingFlags.FlattenHierarchyInclude public and protected static members of base classes (off by default for statics)

The two groups that matter most are the visibility flags (Public / NonPublic) and the kind-of-member flags (Instance / Static). The rule is straightforward but easy to forget: if you want non-public members, you must say so explicitly. If you want static members, you must say so explicitly. The default is "public instance," and anything outside that needs the corresponding flag.

Three observations. The default call to GetFields() returns nothing, because Customer has no public instance fields (the Name property is backed by an auto-generated private field, and TotalCustomers is static). Adding NonPublic | Instance reveals the private _passwordHash and the compiler-generated backing field for Name, named <Name>k__BackingField. Adding Static on top includes TotalCustomers. The compiler-generated backing field name is the reason you don't usually reflect over fields when you mean properties; the metadata is messy.

A second example shows the static-vs-instance split for methods.

DeclaredOnly is what suppresses the object methods (GetType, ToString, Equals, GetHashCode) in this output. Without it, every list would also include those four inherited methods. DeclaredOnly is especially useful for tools that walk a type's own surface without dragging in object.

Each Get... call with a BindingFlags argument runs a fresh metadata scan and allocates a new array. Calling GetFields(...) once per request inside a hot loop is wasteful. The _Inspecting Types at Runtime_ lesson covers caching MemberInfo arrays to avoid the repeated scan.

A small third example combines flags to find all instance constructors, including a private one.

Without NonPublic, only the two-argument constructor would show up. The parameterless private constructor stays hidden behind the default flags. This is exactly how serializers like System.Text.Json reach a private constructor when deserializing into a record or an internal type.

The Cost of Reflection

Reflection isn't free. Every call into System.Reflection does more work than a comparable direct call into compiled IL, and the gap is large enough to matter in hot paths.

There are three sources of cost here, and they compound.

The first is the metadata lookup itself. Calls like Type.GetType("..."), Assembly.GetTypes(), type.GetMethod("Save"), and type.GetProperty("Name") all walk metadata tables and allocate result arrays. A single call returns in microseconds, but a tight loop that does the same lookup over and over is multiplying the cost by however many iterations you run. The cost scales with the number of members on the type: a class with fifty properties takes longer to scan than one with three.

The second is invocation. Once you have a MethodInfo or PropertyInfo, calling .Invoke(...) or .GetValue(...) goes through a generic invocation path that boxes value-type arguments, allocates a parameter array, and dispatches through extra layers of indirection compared to a direct call. The _Inspecting Types at Runtime_ lesson covers this in detail. The rule of thumb is that an invocation through reflection is roughly 50 to 100 times slower than the equivalent direct call.

The third is JIT and assembly loading. The first time you touch a type via reflection, the JIT may have to compile that type's methods, load referenced assemblies, and run static constructors. That cold-path cost is one-time per process but can be significant for large assemblies. Assembly.GetTypes() on a 500-type assembly forces the metadata for every one of those types into the working set.

A single reflection call usually finishes in microseconds, but it does enough hidden work that it's wrong to use it in a hot loop without thinking. If a serializer reflects over a type once at startup and reuses the cached MemberInfo for every subsequent serialization, the cost is amortized to near-zero. If it reflects over the type on every call, it pays the full price every time.

The mitigations come in three layers, in increasing order of effectiveness.

Cache `MemberInfo`. Use the cache-once pattern: look up the PropertyInfo or MethodInfo once when the serializer or mapper is initialized, store it in a dictionary keyed by type, and reuse it for every subsequent call. This eliminates the metadata-lookup cost and leaves only the invocation cost.

Compile a delegate. For higher-throughput needs, you can use Expression<T>.Compile() or Delegate.CreateDelegate(...) to turn a MethodInfo into a real delegate that the runtime can JIT into direct IL. Once compiled, calling the delegate is the same as calling the method directly. This is how Newtonsoft.Json, System.Text.Json, and Dapper get their performance.

Source generators. The fastest option is to do the work at compile time. A source generator inspects the same metadata reflection would inspect, but it runs inside the compiler and emits ordinary C# code that gets compiled alongside your project. There is no runtime reflection at all, no JIT cost, no allocation, and it works on AOT-compiled binaries where reflection is heavily restricted. System.Text.Json's source-generated mode is the canonical example: the serializer ends up as a generated class with direct property reads, indistinguishable in performance from code you'd write by hand.

The diagram shows the escalation. For most application code, the right answer is "don't reflect repeatedly," and that's just caching. For framework code that runs millions of times, compiled delegates pay for themselves. For trimmed or AOT-published applications (Native AOT in .NET 8), source generators are the only option, because the runtime reflection surface is intentionally limited or absent.

The takeaway for this chapter is simpler. Reflection is a fine tool for inspection, configuration, and startup work. It's a poor tool for inner loops in production code. Knowing which side of the line you're on saves a lot of mysterious performance regressions.

Quiz

Reflection Basics Quiz

10 quizzes