Dynamic Type Instantiation and Runtime Inspection in C#
When building extensible console utilities, hard-coding instantiation logic with switch blocks forces modifications for every new implementation. Consider a scenario requiring dynamic creation of derived types based on user input without recompiling the host application.
A base type Organism defines shared state and a virtual operation:
using System;
namespace EcoSystem;
public abstract class Organism
{
public string Species { get; set; }
public int Years { get; set; }
public abstract void Communicate();
}
Two concrete implementations follow:
namespace EcoSystem;
public class Feline : Organism
{
public Feline()
{
Species = "Felis catus";
Years = 2;
}
public override void Communicate() =>
Console.WriteLine($"{Species}, aged {Years}, emits a meow.");
}
namespace EcoSystem;
public class Canine : Organism
{
public Canine()
{
Species = "Canis lupus";
Years = 3;
}
public override void Communicate() =>
Console.WriteLine($"{Species}, aged {Years}, emits a bark.");
}
A naive host program couples the entry point to every known derivative:
using EcoSystem;
using System;
namespace Host;
class EntryPoint
{
static void Main()
{
Console.Write("Enter organism type: ");
var input = Console.ReadLine()?.Trim().ToLower();
Organism entity = null;
switch (input)
{
case "feline": entity = new Feline(); break;
case "canine": entity = new Canine(); break;
}
entity?.Communicate();
}
}
This design violates the Open/Closed Principle. Adding a new species requires editing and redeploying the host. Reflection eliminates this coupling by deferring type resolution to runtime.
Runtime Type Metadata
A compiled .NET application exposes metadata organized hierarchically: AppDomain → Assembly → Module → Type → Member. The Common Language Runtime (CLR) manages this layout. Reflection exposes read-only and executable wrappers around these layers, allowing code to inspect constructors, fields, properties, methods, and events after compilation.
Through reflection, a program can enumerate members of an assembly it was not originally compiled against, instantiate types by string name, and invoke operations without static references.
Core API Surface
The System.Reflection namespace provides the primary toolkit:
System.Type: Gateway to all metadata about a data type. CallingGetType(),typeof(T), orType.GetType(string)yields aTypereference.Assembly: Represents a loaded.dllor.exe. UseAssembly.Load(name)for probing the GAC and application base, orAssembly.LoadFrom(path)for explicit file locations.MethodInfo,PropertyInfo,ConstructorInfo: Concrete descriptors for invocable members.Activator: Factory for creating instances withoutnew.
Retrieving a Type reference can occur three ways:
var instance = "sample";
Type viaInstance = instance.GetType();
Type viaString = Type.GetType("System.String", throwOnError: false, ignoreCase: true);
Type viaOperator = typeof(string);
Type properties such as IsAbstract, IsClass, IsValueType, and IsSealed classify the target, while GetMethods(), GetProperties(), and GetConstructors() return arrays of member descriptors. The non-plural overloads accept binding flags or names to narrow results.
Asesmbly inspection begins with loading:
var asm = Assembly.Load("EcoSystem");
// or
var asm = Assembly.LoadFrom(@"C:\Libs\EcoSystem.dll");
Type[] allTypes = asm.GetTypes();
foreach (var t in allTypes)
{
Console.WriteLine(t.FullName);
}
Reflective Host Implementation
The following host decouples itself from concrete derivatives. It loads an assembly, matches the user string against type names, instantiates dynamically, mutates properties, and invokes a method:
using EcoSystem;
using System;
using System.Linq;
using System.Reflection;
namespace Host;
class EntryPoint
{
static void Main()
{
Console.Write("Enter organism type: ");
var input = Console.ReadLine()?.Trim();
Assembly asm = Assembly.Load("EcoSystem");
Type candidate = asm.GetTypes()
.FirstOrDefault(t =>
t.Name.Equals(input, StringComparison.OrdinalIgnoreCase) &&
typeof(Organism).IsAssignableFrom(t));
if (candidate is null)
{
Console.WriteLine("Unknown organism.");
return;
}
object handle = Activator.CreateInstance(candidate);
PropertyInfo speciesProp = candidate.GetProperty("Species");
PropertyInfo yearsProp = candidate.GetProperty("Years");
speciesProp?.SetValue(handle, "Mutated Strain");
if (yearsProp != null)
{
int current = (int)yearsProp.GetValue(handle);
yearsProp.SetValue(handle, current + 5);
}
MethodInfo action = candidate.GetMethod("Communicate");
action?.Invoke(handle, null);
}
}
Adding a new organism—such as Avian or Reptile—only requires placing a new compiled assembly alongside the host. The host binary remains unchanged.
Performance and Design Considerations
Reflection offers late binding and configuration-driven behavior, but it carries costs. Member access through MethodInfo.Invoke or PropertyInfo.SetValue is orders of magnitude slower than compiled calls because the runtime must resolve metadata and enforce security checks. It also obscures static analysis tools and refactoring utilities, since member names become string literals.
Consequently, reflection suits plugin architectures, serialization engines, and testing utilities where flexibility outweighs execution speed. For hot paths, prefer compiled delegates or source generators once types are known.