HCommons.Reflection 1.0.2

dotnet add package HCommons.Reflection --version 1.0.2
                    
NuGet\Install-Package HCommons.Reflection -Version 1.0.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="HCommons.Reflection" Version="1.0.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="HCommons.Reflection" Version="1.0.2" />
                    
Directory.Packages.props
<PackageReference Include="HCommons.Reflection" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add HCommons.Reflection --version 1.0.2
                    
#r "nuget: HCommons.Reflection, 1.0.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package HCommons.Reflection@1.0.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=HCommons.Reflection&version=1.0.2
                    
Install as a Cake Addin
#tool nuget:?package=HCommons.Reflection&version=1.0.2
                    
Install as a Cake Tool

HCommons.Reflection

HCommons.Reflection discovers types assignable to a class or interface across the assemblies loaded in the current application domain. Results are cached, late-loaded assemblies are merged incrementally, and an included source generator can replace reflection with compile-time type catalogs on an assembly-by-assembly basis.

The library has no dependency on Unity and can be used in ordinary .NET applications, Unity players, editor tooling, plug-in systems, dependency injection, and other type-registration code.

Installation

dotnet add package HCommons.Reflection

The package contains both the runtime library and its source generator. The generator is packed at analyzers/dotnet/cs/HCommons.Reflection.SourceGeneration.dll and is enabled automatically by normal PackageReference builds.

Discovering types

Use the generic API when the base type is known at compile time:

using HCommons.Reflection;

IReadOnlyList<Type> handlers = RuntimeTypeCache.TypesDerivedFrom<IHandler>();

Use the Type overload for runtime-selected queries:

Type contract = SelectContract();
IReadOnlyList<Type> implementations = RuntimeTypeCache.TypesDerivedFrom(contract);

Each result is an immutable snapshot containing every currently loaded type for which baseType.IsAssignableFrom(type) is true, except the queried base type itself. This includes indirect implementations, interfaces, abstract classes, value types, and open generic type definitions when they satisfy normal runtime assignability rules. Result order is unspecified.

Snapshots are safe to retain and enumerate while other assemblies load. A later query returns a new snapshot if new matches have appeared; an existing snapshot is never mutated.

Filtering results

Use a reusable RuntimeTypeFilter for common conditions:

RuntimeTypeFilter filter = RuntimeTypeFilters
    .Concrete()
    .Public()
    .Closed();

IReadOnlyList<Type> handlers =
    RuntimeTypeCache.TypesDerivedFrom<IHandler>(filter);

Built-in filters are represented as flags in a non-generic readonly struct. Constructing and chaining the built-in conditions does not allocate on the managed heap:

  • Concrete() excludes abstract types and interfaces.
  • Public() requires Type.IsVisible, including the visibility of containing types.
  • Closed() excludes types containing unassigned generic parameters.
  • HasPublicParameterlessConstructor() accepts value types or types with a public parameterless constructor.
  • Instantiable() combines concrete, closed, and public-parameterless-constructor conditions. Append Public() separately when external type visibility is also required.

The default RuntimeTypeFilter matches every non-null type. Filters can also be evaluated outside the cache:

bool accepted = filter.Matches(typeof(FileHandler));
IEnumerable<Type> acceptedTypes = candidateTypes.Where(filter.Matches);

Combining filters

Chained conditions use Boolean AND by default. And accepts a separately constructed group, Or combines the complete accumulated expression with an alternative, and instance Not means AND NOT:

RuntimeTypeFilter filter = RuntimeTypeFilters
    .Concrete()
    .Public()
    .Not(RuntimeTypeFilters.Where(new NamespaceRule("Framework")))
    .Or(RuntimeTypeFilters.Where(new ExactTypeRule(typeof(FallbackHandler))));

Composition is left-associative. The example means:

((Concrete AND Public) AND NOT FrameworkNamespace) OR ExactFallbackType

Group the right side explicitly when needed:

RuntimeTypeFilter filter = RuntimeTypeFilters.Concrete().Or(
    RuntimeTypeFilters.Public().Closed());

Use the static form to negate a complete expression:

RuntimeTypeFilter nonPublic = RuntimeTypeFilters.Not(RuntimeTypeFilters.Public());

And and Or short-circuit in expression order. Not evaluates its operand once and inverts the result. Simple built-in AND chains retain the allocation-free flag representation; rules and compound Boolean expressions use immutable expression nodes.

Custom conditions and rules

For one-off or stateful logic, append a delegate with Where:

RuntimeTypeFilter enabledHandlers = RuntimeTypeFilters
    .Concrete()
    .Where(type => configuration.IsEnabled(type));

A delegate has no stable structural identity, so any filter containing one is uncacheable. Its captured state is reevaluated on every query or binding update.

When constructing the filter repeatedly, pass state separately and use a static lambda to avoid allocating a closure:

RuntimeTypeFilter enabledHandlers = RuntimeTypeFilters
    .Concrete()
    .Where(
        configuration,
        static (configuration, type) => configuration.IsEnabled(type));

Where<TState>(TState, Func<TState, Type, bool>) stores value-type state directly in its generic expression node without boxing. The expression node itself is still allocated, and reference-type state is stored by reference. This form remains uncacheable because passing state separately does not prove that its matching behavior or identity is immutable. Prefer a static lambda so the compiler can reuse its delegate; use an immutable RuntimeTypeFilterRule when the custom condition needs filtered-snapshot caching.

For a reusable cacheable condition, derive an immutable record from RuntimeTypeFilterRule:

public sealed record NamespaceRule(string Namespace) : RuntimeTypeFilterRule {
    public override bool Matches(Type type) => type.Namespace == Namespace;
}

RuntimeTypeFilter gameTypes = RuntimeTypeFilters
    .Concrete()
    .Where(new NamespaceRule("My.Game"));

Every value affecting Matches must participate in record equality, and a rule must not depend on mutable external state. Equal rule records produce structurally equal filter descriptors.

The original Func<Type, bool> overloads remain available for concise uncached calls:

IReadOnlyList<Type> concreteHandlers = RuntimeTypeCache.TypesDerivedFrom<IHandler>(
    type => !type.IsAbstract && !type.IsInterface);

Opt-in filtered snapshot caching

Call Cached() when the same cacheable descriptor will be evaluated repeatedly:

RuntimeTypeFilter filter = RuntimeTypeFilters
    .Concrete()
    .Public()
    .Cached();

IReadOnlyList<Type> handlers = RuntimeTypeCache.TypesDerivedFrom<IHandler>(filter);

Caching is explicit and behaves the same for TypesDerivedFrom and Bind. A cached snapshot is stored per exact base type and structural filter expression. An equivalent descriptor without Cached() can reuse an existing entry but does not create one. Clear() and changes to the underlying base-type snapshot invalidate cached filtered results.

Calling Cached() on a descriptor containing either delegate-based Where overload remains correct but does not cache its result. Analyzer warning HCRTCFILTER001 identifies directly constructed fluent chains where the request cannot be honored. Use a RuntimeTypeFilterRule when custom logic has a stable, immutable value identity.

Filtering happens after the shared base-type query is resolved. Generated catalogs and reflected assemblies therefore have identical behavior. The filter receives only types already assignable to the requested base type, and the base type itself has already been excluded. Returned snapshots are immutable and their order is unspecified.

Filtered bindings use the argument order filter, then callback:

using IDisposable subscription = RuntimeTypeCache.Bind<IHandler>(
    RuntimeTypeFilters.Concrete().Cached(),
    handlers => RebuildHandlerRegistry(handlers));

They deliver an initial filtered snapshot synchronously and notify again only when the filtered type set changes. A newly loaded assignable type that fails the predicate does not trigger the user callback. Delegate predicates should remain behaviorally stable for the lifetime of a subscription; dispose and recreate the binding when captured criteria change. Filters run on the same thread or synchronization context as the corresponding callback. Query filter exceptions propagate to the caller; binding filter exceptions other than OutOfMemoryException are written as trace warnings.

Observing late-loaded assemblies

Bind delivers an initial snapshot synchronously and replacement snapshots when newly loaded assemblies add matching types:

using IDisposable subscription = RuntimeTypeCache.Bind<IHandler>(handlers => {
    RebuildHandlerRegistry(handlers);
});

The overload without a synchronization-context argument captures SynchronizationContext.Current when the binding is created. Supply a context explicitly when callbacks must run on a particular thread:

SynchronizationContext uiContext = SynchronizationContext.Current!;

using IDisposable subscription = RuntimeTypeCache.Bind<IHandler>(
    handlers => RebuildUi(handlers),
    uiContext);

Pass null to dispatch later notifications through the thread pool:

using IDisposable subscription = RuntimeTypeCache.Bind<IHandler>(OnHandlersChanged, null);

Dispose the returned subscription to stop notifications. Rapid changes can be coalesced into the latest snapshot. Callback exceptions other than OutOfMemoryException are written as trace warnings and do not stop the cache worker.

The equivalent runtime-type overloads are also available:

using IDisposable subscription = RuntimeTypeCache.Bind(
    typeof(IHandler),
    OnHandlersChanged);

Clearing the cache

Call RuntimeTypeCache.Clear() when cached assembly contents must be rebuilt:

RuntimeTypeCache.Clear();
IReadOnlyList<Type> handlers = RuntimeTypeCache.TypesDerivedFrom<IHandler>();

Without active bindings, rebuilding is deferred until the next query or binding. With active bindings, a background rebuild is scheduled and a replacement snapshot is delivered only when the result changed. Bindings remain registered across Clear().

Assembly-load notifications detect newly loaded assemblies, but they cannot detect types added to an already loaded dynamic assembly. Reflection-backed queries can use Clear() to rescan such an assembly. A complete generated catalog is fixed at compilation time and intentionally remains the authority for its assembly/base-type pair.

The cache and returned snapshots hold strong references to Assembly and Type instances. Clear the cache, dispose bindings, and release snapshots before unloading a collectible AssemblyLoadContext.

Source generation

Source generation is a transparent optimization. Runtime behavior remains correct through reflection whenever a generated catalog is absent or incomplete.

Automatically discovered queries

The generator recognizes concrete generic calls:

RuntimeTypeCache.TypesDerivedFrom<IHandler>();
RuntimeTypeCache.TypesDerivedFrom<IHandler>(type => !type.IsAbstract);
RuntimeTypeCache.Bind<IHandler>(OnHandlersChanged);
RuntimeTypeCache.Bind<IHandler>(type => !type.IsAbstract, OnHandlersChanged);

It also recognizes a direct typeof(...) argument:

RuntimeTypeCache.TypesDerivedFrom(typeof(IHandler));
RuntimeTypeCache.TypesDerivedFrom(typeof(IHandler), type => !type.IsAbstract);
RuntimeTypeCache.Bind(typeof(IHandler), OnHandlersChanged);
RuntimeTypeCache.Bind(typeof(IHandler), type => !type.IsAbstract, OnHandlersChanged);

Aliases and fully qualified method calls are supported because discovery uses Roslyn symbols, not method-name text.

Queries routed through source-visible generic wrapper methods are also recognized when their type arguments become concrete at a call site:

static void RegisterHandlers() {
    RegisterAll<ICommandHandler>();
    RegisterAll<IEventHandler>();
}

static void RegisterAll<TContract>() {
    _ = RuntimeTypeCache.TypesDerivedFrom<TContract>(
        RuntimeTypeFilters.Instantiable().Cached());
}

Wrapper inference follows transitive method calls within the current compilation. It substitutes method and containing-type parameters, including constructed query types such as IHandler<T> and both generic and typeof(T) cache overloads. Recursive wrapper chains are bounded; any query that does not become a closed type keeps the normal reflection fallback.

The generator cannot inspect wrapper bodies that exist only in referenced assemblies, and it does not infer calls made indirectly through delegates or method groups. Use [GenerateRuntimeTypeCache] on the shared base contract when query propagation across assembly boundaries is required.

Predicates do not change catalog completeness. The generator records every assignable type for the base query, and the runtime applies the predicate to that complete snapshot. The predicate itself is not analyzed or executed at compile time.

A runtime Type variable is not a compile-time query and therefore uses reflection:

Type contract = SelectContract();
RuntimeTypeCache.TypesDerivedFrom(contract);

Open generic base queries and base types that remain parameterized after wrapper inference are also left to reflection.

Queries shared across assemblies

Apply [GenerateRuntimeTypeCache] to a shared base contract when implementations live in other assemblies:

using HCommons.Reflection;

[GenerateRuntimeTypeCache]
public interface IHandler { }

The contract assembly publishes the query in its metadata. Every downstream assembly compiled with the HCommons generator sees that metadata and emits its own catalog of locally declared implementations.

For reliable cross-assembly coverage:

  1. Reference HCommons.Reflection directly from the contract project and every project that declares implementations. Do not assume analyzer assets flow through an unrelated package or project reference.
  2. Build the contract assembly before its implementation assemblies. Normal project references establish this order automatically.
  3. Keep matching types accessible from generated assembly-level code. Top-level public and internal types work. Private nested and file-local matching types make the catalog incomplete.
  4. Ensure implementations are visible as C# source during the compilation. Types introduced later by IL weaving or a peer source generator cannot be included in this generator's catalog.

One inaccessible matching type makes that assembly/base-type catalog incomplete and produces the informational diagnostic HCRTCGEN001. The runtime then reflects over the assembly so that no discoverable types are lost.

What a generated catalog looks like

For this source:

[GenerateRuntimeTypeCache]
public interface IHandler { }

public sealed class FileHandler : IHandler { }
internal sealed class NetworkHandler : IHandler { }

the generator emits metadata equivalent to:

[assembly: RuntimeTypeCacheGeneratedTypes(
    typeof(IHandler),
    true,
    typeof(FileHandler),
    typeof(NetworkHandler))]

Catalog completeness is evaluated separately for every assembly and exact base type. A complete catalog allows RuntimeTypeCache to skip Assembly.GetTypes() only for that pair. Other queries or assemblies without complete catalogs retain reflection fallback.

An empty complete catalog is meaningful:

[assembly: RuntimeTypeCacheGeneratedTypes(typeof(IHandler), true)]

It states that the declaring assembly contains no types assignable to IHandler, allowing the runtime to skip scanning that assembly for this query.

Manually declaring a catalog

RuntimeTypeCacheGeneratedTypesAttribute is public so generated build tooling can communicate with the runtime, but application code normally should not use it directly. The bundled source generator is safer because an incorrect complete catalog causes real implementations to be omitted from results.

If another build-time tool already knows the complete type set, place the attribute at assembly level in any source file belonging to the assembly being described:

using HCommons.Reflection;

[assembly: RuntimeTypeCacheGeneratedTypes(
    typeof(IHandler),
    true,
    typeof(FileHandler),
    typeof(NetworkHandler))]

The rules are strict:

  • The attribute describes only types declared by its own assembly; do not list implementations from another assembly.
  • List every declared type assignable to the exact base type, excluding the base type itself.
  • isComplete: true promises that the list is exhaustive. An empty list promises there are no matches in the assembly.
  • Multiple attributes for the same base type are combined, which permits chunking large lists. Every chunk must have isComplete: true; one incomplete chunk forces reflection.
  • Every listed type must be non-null, different from the base type, and assignable to it. A malformed entry makes the combined catalog incomplete and forces reflection.
  • isComplete: false deliberately requests reflection. Listed types are not used as a partial optimization because the full assembly must still be scanned for correctness.

For example, a manually chunked complete catalog is valid:

[assembly: RuntimeTypeCacheGeneratedTypes(
    typeof(IHandler), true, typeof(FileHandler))]

[assembly: RuntimeTypeCacheGeneratedTypes(
    typeof(IHandler), true, typeof(NetworkHandler))]

Do not manually add this attribute merely to enable generation. Use [GenerateRuntimeTypeCache] on the base contract for that purpose.

Verifying generator coverage

Reflection fallback makes missing generator configuration easy to overlook. A test in each implementation assembly can enforce complete coverage:

using System.Reflection;
using HCommons.Reflection;

Assembly assembly = typeof(FileHandler).Assembly;

RuntimeTypeCacheGeneratedTypesAttribute[] catalogs = assembly
    .GetCustomAttributes<RuntimeTypeCacheGeneratedTypesAttribute>()
    .Where(catalog => catalog.BaseType == typeof(IHandler))
    .ToArray();

Assert.NotEmpty(catalogs);
Assert.All(catalogs, catalog => Assert.True(catalog.IsComplete));

Large catalogs are split across multiple attributes, so verify all matching entries rather than expecting exactly one.

Unity

The generator targets .NET Standard 2.0 and Microsoft.CodeAnalysis.CSharp 4.3, matching the Unity 6 source-generator toolchain. It does not use module initializers.

When installing through NuGetForUnity, verify that HCommons.Reflection.SourceGeneration.dll is imported as an analyzer:

  • All plug-in platforms are disabled.
  • The exact, case-sensitive RoslynAnalyzer asset label is assigned.
  • The analyzer's Unity folder/assembly-definition scope includes every .asmdef that declares implementations.

Older NuGetForUnity versions can apply analyzer import settings after Unity's first asset refresh. If Unity initially tries to load Microsoft.CodeAnalysis as a player/runtime dependency, update NuGetForUnity or correct the generator DLL's importer settings and reimport it.

Unity managed-code stripping can remove types that are discovered only through reflection. Use link.xml, [UnityEngine.Scripting.Preserve], or another linker-preservation mechanism for reflection-backed types. Generated catalog entries create static type references, but preservation requirements should still be validated for each IL2CPP/linker configuration.

Trimming and Native AOT

The discovery APIs carry RequiresUnreferencedCode on modern .NET targets because reflection fallback cannot guarantee that matching types survive trimming. Complete generated catalogs reduce reflection, but uncovered assemblies and runtime-selected queries still require preservation configuration. Treat trimming warnings as actionable unless every relevant assembly/base-type pair is covered and the resulting publication has been verified.

Performance characteristics

Cached queries return an immutable snapshot without rescanning assemblies. Generation primarily optimizes cold queries, cache rebuilds, and late assembly processing by avoiding Assembly.GetTypes() for covered assembly/base-type pairs.

The aggregate improvement depends on coverage. A process containing many uncovered framework, third-party, or plug-in assemblies can still spend most of a cold query in reflection even when the application's own catalog is complete. Measure the intended application or Unity player assembly layout rather than assuming a whole-process speedup.

Thread safety

Public cache operations are synchronized and can be called from multiple threads. Returned snapshots are immutable. Bind callback threading is controlled by the captured or supplied SynchronizationContext; callbacks should still avoid blocking for long periods.

Target frameworks

  • .NET 9.0
  • .NET 8.0
  • .NET Standard 2.1
  • .NET Standard 2.0

License

HCommons.Reflection is distributed under the MIT license.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 is compatible. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.0

    • No dependencies.
  • .NETStandard 2.1

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on HCommons.Reflection:

Package Downloads
HCommons

Commonly used types, functions, and extensions.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.2 292 8/12/2026
1.0.1 201 8/11/2026
1.0.0 102 8/10/2026

Automated release for **HCommons.Reflection** version **1.0.2**.

## What Changed

- feat: infer runtime type cache queries through generic wrappers (#67) ([27d44ab](https://github.com/Hissal/HCommons/commit/27d44abe09e984a2d03f24bbc7e5926970c8913c))