Terms of use?
By using this project or its source code, for any purpose and in any shape or form, you grant your implicit agreement to all the following statements:
- You condemn Russia and its military aggression against Ukraine
- You recognize that Russia is an occupant that unlawfully invaded a sovereign state
- You support Ukraine's territorial integrity, including its claims over temporarily occupied territories of Crimea and Donbas
- You reject false narratives perpetuated by Russian state propaganda
To learn more about the war and how you can help, click here. Glory to Ukraine!
Turn a small, fixed set of options into CLR types, so generic static caches can be keyed by both a data type and a preset without a dictionary lookup on the typed hot path.
.NET gives every closed generic type its own static fields. This makes a generic type a convenient per-type cache:
static class MetadataCache<T>
{
public static readonly Metadata Value = Build(typeof(T));
}MetadataCache<Invoice> and MetadataCache<Customer> each initialize and store a different Value. Microsoft documents this behavior explicitly: each closed generic type has its own copy of static members.
The pattern stops being direct when the cache key also contains values such as a bool, an enum, or BindingFlags: C# cannot use arbitrary values as generic arguments. The usual fallback is a runtime lookup:
Dictionary<(Type, FieldOptions), Metadata> cache;TypeLevelPresets generates a marker type for each declared option tuple. The cache can then use a distinct closed generic type for every known combination:
MetadataCache<Invoice, FieldOptions.Public>.Valueusing System.Reflection;
using Raffinert.TypeLevelPresets;
[Preset("Public", BindingFlags.Instance | BindingFlags.Public)]
[Preset("AllInstance",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)]
public readonly partial struct FieldOptions
{
public BindingFlags Flags { get; }
}
public static class Cache<T, TPreset>
where TPreset : struct, IPreset<FieldOptions>
{
public static readonly FieldInfo[] Value = typeof(T).GetFields(
FieldOptions.GetValues<TPreset>().Flags);
}
var fields = Cache<Invoice, FieldOptions.Public>.Value;The generator emits:
- a zero-field
readonly structmarker for each[Preset]; - an immutable
FieldOptionsValuesvalue type; - a cached
FieldOptions.GetValues<TPreset>()accessor.
The preset values are emitted as code. The typed path uses no attribute reflection and no options dictionary. Each Cache<T, TPreset> combination owns independent static storage and initializes it once.
Use this pattern when all of the following are true:
- the presets are finite and known at build time;
- the same type-and-preset combinations are accessed repeatedly;
- a lookup sits on a measured hot path;
- separate generic-static storage is useful for metadata, delegates, serializers, mappers, or similar reusable artifacts.
Keep a normal dictionary for runtime-selected, user-defined, or high-cardinality options. This package is not a general configuration system. Every additional closed generic combination can add initialization work and may add JIT or native-code size, so benchmark the complete workload rather than only the lookup.
A cache can accept the object type as a generic parameter and recognize a small set of hot options inside Get<TObject>(options). Recognized options return a closed generic static for TObject; all other (type, options) pairs fall back to a concurrent dictionary:
public static Metadata Get<TObject>(RuntimeOptions options)
{
if (options == RuntimeOptions.Public)
return Cache<TObject, GeneratedOptions.Public>.Value;
// Add the other measured hot options above this fallback.
return fallback.GetOrAdd(
(typeof(TObject), options),
static key => Build(key.Type, key.Options));
}This avoids runtime type recognition: the JIT closes the cache separately for each TObject. Option recognition is still part of every lookup, so its cost and the workload's hot/cold distribution should be benchmarked. The serialization sample contains a complete version with five example hot calls and a ConcurrentDictionary<(Type, options), ...> fallback.
Schemas are top-level, non-generic, partial classes or structs. Their readable instance properties, in declaration order, define the tuple. Version 1 supports bool, integral primitives, char, string, enums, and System.Type supplied as typeof(...).
Floating point, nullable values, arrays, nested schemas, and schemas split across multiple user declarations are rejected. Invalid names or values, duplicate presets, and generated-name collisions produce TLP001–TLP011 compiler diagnostics.
The benchmark preloads 100 dictionary entries and generates 100 presets. Each invocation reads all 100 values, and BenchmarkDotNet reports the time per lookup. Run it with:
dotnet run -c Release --project benchmarks/TypeLevelPresets.Benchmarks -- --filter '*FieldMetadataCacheBenchmarks*'Run the hybrid benchmark, which varies recognized-pair traffic between 0%, 50%, 95%, and 100%, with:
dotnet run -c Release --project benchmarks/TypeLevelPresets.Benchmarks -- --filter '*HybridCacheBenchmarks*'Representative short-run results from the same environment as the table below:
| Recognized-pair traffic | Concurrent dictionary only | Hybrid | Hybrid ratio |
|---|---|---|---|
| 0% | 6.106 ns | 6.013 ns | 0.98 |
| 50% | 6.227 ns | 3.732 ns | 0.60 |
| 95% | 6.082 ns | 1.041 ns | 0.17 |
| 100% | 6.007 ns | 0.624 ns | 0.10 |
This benchmark uses Get<TObject>(options), allowing the JIT to bind the object type while the method recognizes the five hot options. The 0% row shows fallback performance close to the concurrent-only cache, and the benefit grows with hot-option traffic. Measure with a distribution representative of the application.
Representative short run on Windows 11 with .NET 10.0.11, x64 RyuJIT, and AVX2 (BenchmarkDotNet 0.15.2; three warmups and three measured iterations):
| Access path | Mean per lookup | Allocated |
|---|---|---|
| Composite dictionary with 100 entries | 6.6450 ns | 0 B |
| Direct generic static across 100 presets | 0.0013 ns¹ | 0 B |
¹ BenchmarkDotNet reported the generic-static read as indistinguishable from an empty method. Treat the near-zero value as below measurement resolution, not as a literal access time or an exact speedup. The benchmark shows that this typed path removed measurable dictionary-lookup overhead; it does not promise the same application-level gain.
- Static classes and members — documents separate static members for each closed generic type.
- Know Thine Implicit Allocations — demonstrates the
DelegateCache<T>form of the generic-static cache pattern. - Performance improvements in .NET 8 — discusses a real runtime-library optimization involving generic static fields and generic lookup overhead.
- .NET shared-generics design — explains why generic instantiations can involve runtime generic dictionaries and code sharing.
dotnet build TypeLevelPresets.slnx
dotnet test TypeLevelPresets.slnx
dotnet pack src/TypeLevelPresets -c ReleaseThe TypeLevelPresets package contains net45 and netstandard2.0 abstraction assemblies under lib/ and the incremental generator under analyzers/dotnet/cs/. Consumers install one package; Roslyn does not become a runtime dependency. See the changelog for release history.
See samples/TypeLevelPresets.Sample.Serialization for the reflection metadata cache example. Interceptors, runtime-generated types, arbitrary runtime values, and call-site rewriting are not part of v1.