HCommons.Reflection
1.0.2
dotnet add package HCommons.Reflection --version 1.0.2
NuGet\Install-Package HCommons.Reflection -Version 1.0.2
<PackageReference Include="HCommons.Reflection" Version="1.0.2" />
<PackageVersion Include="HCommons.Reflection" Version="1.0.2" />
<PackageReference Include="HCommons.Reflection" />
paket add HCommons.Reflection --version 1.0.2
#r "nuget: HCommons.Reflection, 1.0.2"
#:package HCommons.Reflection@1.0.2
#addin nuget:?package=HCommons.Reflection&version=1.0.2
#tool nuget:?package=HCommons.Reflection&version=1.0.2
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()requiresType.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. AppendPublic()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:
- Reference
HCommons.Reflectiondirectly from the contract project and every project that declares implementations. Do not assume analyzer assets flow through an unrelated package or project reference. - Build the contract assembly before its implementation assemblies. Normal project references establish this order automatically.
- Keep matching types accessible from generated assembly-level code. Top-level
publicandinternaltypes work. Private nested and file-local matching types make the catalog incomplete. - 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: truepromises 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: falsedeliberately 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
RoslynAnalyzerasset label is assigned. - The analyzer's Unity folder/assembly-definition scope includes every
.asmdefthat 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 | Versions 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. |
-
.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.
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))