Skip to content

Repository files navigation

Stand With Ukraine

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!

TypeLevelPresets

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.

Why

.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>.Value

Quick start

using 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 struct marker for each [Preset];
  • an immutable FieldOptionsValues value 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.

Good fit

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.

Hybrid generic API

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.

Supported values and rules

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 TLP001TLP011 compiler diagnostics.

Benchmark

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.

Background reading

Build and test

dotnet build TypeLevelPresets.slnx
dotnet test TypeLevelPresets.slnx
dotnet pack src/TypeLevelPresets -c Release

The 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.

About

Source-generated type-level presets for C#—turn finite compile-time options into marker types for dictionary-free generic-static cache access.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages