We have open-sourced FancyEnumGenerator, the source generator we use for enums across HCTI. You declare each member's wire name, label or any other value as an attribute, and it generates the formatting, parsing and lookup code at compile time. It is on NuGet and on GitHub.
HCTI's codebase defines over 300 enums, and at least half of them use FancyEnumGenerator for some kind of mapping, parsing or formatting. They range from enums with a single member to CssPatternName, which lists the 156 background patterns in the Template Editor, each with its own display name.
Most of them carry more than their names. API key permissions are a good example. Each permission belongs to a product area and an action, is either a read or a write, and has a short and a long description. The dashboard, our OpenAPI docs, OAuth scopes and the MCP tools all read that metadata. Without a generator, every one of those facts is a separate hand-written switch, and adding a permission means finding and updating all of them:
[Flags]
public enum ApiPermission : ulong
{
UNKNOWN = 0,
ImagesCreate = 1UL << 0,
ImagesRead = 1UL << 1,
TemplatesRead = 1UL << 3,
}
public static class ApiPermissionExtensions
{
public static ApiProductArea ProductArea(this ApiPermission permission) => permission switch
{
ApiPermission.ImagesCreate or ApiPermission.ImagesRead => ApiProductArea.images,
ApiPermission.TemplatesRead => ApiProductArea.templates,
_ => ApiProductArea.UNKNOWN
};
public static ApiProductAction ProductAction(this ApiPermission permission) => permission switch
{
ApiPermission.ImagesCreate => ApiProductAction.create,
ApiPermission.ImagesRead or ApiPermission.TemplatesRead => ApiProductAction.read,
_ => ApiProductAction.UNKNOWN
};
public static string ShortDescription(this ApiPermission permission) => permission switch
{
ApiPermission.ImagesCreate => "Create images and generate signed create-and-render URLs.",
ApiPermission.ImagesRead => "List images and view their details.",
ApiPermission.TemplatesRead => "List and view templates and their versions.",
_ => "No product operations are allowed."
};
// ...plus ReadWrite, Description, formatting combined flags as text,
// and parsing them back, each kept in sync with the others by hand.
}
The "after" version has no switches to forget. Adding a permission is one member with its attributes, and its area, action, description and flags handling all come along with it. Everything is generated at compile time as plain C#, so there is no reflection at runtime and no cache to build on first use.
Where It Came From
I have used NetEscapades.EnumGenerators for a long time, and it is what sold me on the idea: describe the enum once and let the compiler write the boring code. It is also a great example of how to build a source generator well, and it has features we do not have (yet), like interceptors that swap calls to the built-in ToString() and HasFlag() for its generated versions without changing your code.
We wrote our own because we kept writing more and more enum extension methods by hand, one enum at a time, each with its own naming and its own idea of what to do with a bad value. Centralizing them is better hygiene. Every enum gets the same members with the same names and the same fallback rules, and the metadata lives on the enum instead of in a helper class somewhere else in the project.
It also made working with AI a lot better. The convention is simple and very clear: if an enum needs a label, a wire name or a parser, you add an attribute. An agent picks that up quickly and follows it, instead of writing yet another switch, and the change is easy to review because it is one line next to the member it describes.
So the first version was an internal generator, called EnumStuffSourceGen because naming is hard. It read attributes on our enums and generated the extension members and switches, and it has been running in production for a while. It is the same idea as the source generators behind the Template Editor: keep the intent next to the declaration and generate the predictable plumbing around it.
Related Reading
The Template Editor source generators post covers the same pattern for UI metadata, and the MemoryPack post touches on the source generators and analyzers we use to keep stored models safe.
From Internal Tool to Package
We wanted to open-source it, partly so other .NET developers could use it and partly so we could use the same package in our own .NET client. As an internal tool, it was pretty ugly. I want to be proud of the code I put out in the world, and definitely want to make sure the code you're putting into your product meets a high bar. The underlying structure was pretty solid, but a lot of things needed reworking for general use.
This also meant introducing things like overridable defaults on the build or assembly level. I like certain bits of generated code to come out as .cs but I know that's not the norm. So now I can set <FancyEnumUseGeneratedFileSuffix>false</FancyEnumUseGeneratedFileSuffix> in my Directory.Build.props and get plain .cs files instead of .g.cs for the generated code we check in. The default doesn't need to match my peculiar preferences! There are a bunch of defaults and settings you can set like this.
Beyond settings, turning it into a package meant a few other deliberate choices:
- Diagnostics instead of surprises. Sixteen of them, from a missing
Unknownmember to two members that would produce the same parser token. The generator should tell you your enum is ambiguous. One core tenet you may have noticed: explicit over implicit. - A real way to define your own attributes. Internally we had a trick where your own attribute could inherit from our member attribute. It worked, but it was hard to explain and made things more complicated. The package replaces it with member sets, like the
ApiPermissionMetaattribute above: mark an attribute class with[FancyEnumMemberSet]and each of its properties becomes a generated field. - Tests, docs and benchmarks. The test suite covers the generator itself, the generated code at runtime and the packed NuGet package on both .NET 10 and .NET Framework. The docs explain every setting and why the generated code looks the way it does.
Then we had to move our own code over to it, which was the real test. Those enums are spread across eight projects, and several of our other source generators read their attributes or call the generated methods.
Most of the migration was a rename. The interesting part was checking that nothing changed behavior along the way. We wrote a small script that took every file the old generator had produced, found the file the new one produced for the same enum, and compared them member by member: every generated property, every mapped value and every parser token.
It found exactly one real problem. The new package skips [Obsolete] members by default, and one of our template enums still had an obsolete value that persisted templates could reference. It would have compiled fine and stopped round-tripping. We already had a setting for this, so the fix was one line on that enum. That check was a lot cheaper than finding out from a customer.
Making the Parser Fast
Formatting an enum is easy to make fast. ToStringFancy() is a switch that returns a string literal, which is about as fast as C# gets. FancyEnum, NetEscapades and Enums.NET all come in under a nanosecond there, against about 7 ns and an allocation for the BCL's ToString(). Parsing is where the interesting work is.
The obvious parser is a switch over string constants, and for small enums that is exactly what FancyEnumGenerator generates. For larger ones it switches on the input's length first, then hands each length to the compiler's own string-switch lowering, which picks a single character position that tells the candidates apart. A miss usually fails the length check without comparing a single string.
UTF-8 input took a different path. Earlier this year I saw Marc Gravell's post about matching ASCII by packing bytes into integers, the same idea behind AsciiHash in StackExchange.Redis. The generator packs each token's first eight bytes into a ulong at compile time. At runtime it packs the input the same way and switches on that number, with no conversion from bytes to a string at all.
One Big Switch That Got Slower
My favorite finding from the benchmarks was a change that made things worse.
At one point the generator put every string case for an enum into one big method. It looked tidy. On a 200-member enum it was about three times slower than the version before it: 139 ns for a hit, and 111 ns even for a miss, which should have been nearly free.
The cause does not show up in the C# at all. Each string case leaves a span temporary in the method. Past a certain number of locals the JIT stops tracking them individually and zero-initializes all of them in the method's prologue, on every call, before your code runs. A miss paid for every case it never looked at.
The fix was to split the work up. The public method only switches on length, and any length with more than a few names gets its own small private method:
private static bool TryParseFancy__Exact14(ReadOnlySpan<char> input, out HttpHeader result)
{
switch (input)
{
case "Accept-Charset":
result = HttpHeader.AcceptCharset; return true;
case "Content-Length":
result = HttpHeader.ContentLength; return true;
// ...
}
result = default;
return false;
}
Each method now has a handful of locals, and the same benchmark dropped from 139 ns to about 8 ns. The version that looked cleaner was the slow one, and only measuring showed it.
How It Compares
Here is where parsing ended up on .NET 10, against the BCL, NetEscapades.EnumGenerators and Enums.NET:
| Parsing a member name | BCL | FancyEnum | NetEscapades | Enums.NET |
|---|---|---|---|---|
| Hit, 25 members | 46.0 ns | 3.6 ns | 36.3 ns | 14.9 ns |
| Hit, 200 members | 263.7 ns | 7.5 ns | 227.4 ns | 14.2 ns |
| Miss, 200 members | 485.5 ns | 7.3 ns | 351.3 ns | 13.1 ns |
Enums.NET deserves credit here. It builds a dictionary per enum type and is very consistent at any size. A generated switch wins because there is nothing to hash and no cache to build, but it costs code size in your assembly instead. The full benchmark reports include formatting, flags, UTF-8 and metadata lookups, and every benchmark checks during setup that all the libraries give the same answer.
Enumerating Without Allocating
The other thing we kept writing by hand was a cache for an enum's members. NetEscapades' GetValues() returns a new array on every call. It has to, since arrays are mutable and a shared one could be changed by any caller. So all over our code we had lines like this:
private static readonly ApiPermission[] _allPermissions = ApiPermissionExtensions.GetValues();
That works, but it is one more thing to remember for every enum, and each of those arrays lives on the heap for the lifetime of the app.
FancyEnumGenerator's Values returns an inline array instead. An inline array is a struct marked with [InlineArray(N)], a .NET 8 feature that tells the runtime to lay out N copies of its single field back to back, like a fixed-size buffer. It is a value type, so it lives on the stack or inside whatever holds it, and it never touches the heap. The generator emits one per enum, sized to the number of members:
[InlineArray(21)]
public struct ApiPermissionArray
{
private ApiPermission _element0;
}
extension(ApiPermission)
{
public static ApiPermissionArray Values
{
get
{
ApiPermissionArray values = default;
values[0] = ApiPermission.ImagesCreate;
values[1] = ApiPermission.ImagesRead;
// ...
return values;
}
}
}
Every call gets its own copy, which is 21 ulongs, or 168 bytes, for our permissions. That copy is the whole cost. Nothing is allocated, nothing needs caching and nobody can change the members out from under anyone else. Unknown is left out, since you almost never want it in a list of choices.
Inline arrays support indexing and foreach directly, and they convert to a Span<T> when you want to hand them to something else:
foreach (var permission in ApiPermission.Values)
{
// ...
}
var values = ApiPermission.Values;
Span<ApiPermission> span = values;
var reads = span.AsValueEnumerable().Where(static p => p.ReadWrite == ApiPermissionReadWrite.READ).ToArray();
The last line uses ZLinq, so the filtering itself doesn't allocate. Only the final array does.
In fairness, .NET 10's JIT can sometimes avoid NetEscapades' allocation too. In our enumeration benchmark the array never leaves the loop, so the JIT puts it on the stack and both libraries come in at about 9 ns with nothing allocated, against 30 ns and 128 B for Enum.GetValues. Once the array is stored or passed along, though, that optimization no longer applies, and you cannot count on it on older runtimes at all.
When You Want a Static Collection
Sometimes a cached copy really is what you want: a hot loop that should not copy anything, or a target without inline arrays. We did not want to be presumptuous and put a static collection on every enum, so it is opt-in:
[FancyEnum(CreateStaticReadonlyCollection = true)]
public enum ImageFormat : byte { /* ... */ }
With that set, the members are cached in a static field the first time you ask for them, and you also get AsSpan, a ReadOnlySpan<T> over that cached storage with no copy at all. On .NET Framework and other targets without inline arrays, this is the only way to get Values, and it comes back as an IReadOnlyList<T> backed by a static array.
Where AI Helped
I used AI a lot on this project, and it was most useful for the parts that are tedious to do well.
It wrote a large share of the tests. That is how we found that an enum parsing case-insensitively by default could drop exact spellings from its case-sensitive overload. With members like TE and Te, one of them simply could not be parsed exactly. It also built the benchmark project, the fairness checks and the script that compared our old generated code with the new.
It was also a fast way to try ideas. The one big switch was one of those ideas, and it was a bad one. What made AI useful was the loop around it: generate a variant, read the generated code, benchmark it and keep only what held up. Without the benchmarks and tests we would have happily shipped the slower version.
What's Next
Two ideas for what comes next. The first is the closed enums proposal for C#. Today (ApiPermission)12345 compiles, which is why we need Unknown members and fallback arms at all. A closed enum could only ever hold its declared members, so generated code could trust a value without checking it first.
The second is generating a metadata record per enum, so a member's area, action and descriptions can travel together as one value you pass to a component or serialize, instead of a key you look everything up with.
Conclusion
The original goal was small: stop writing the same switches and keep an enum's behavior next to the enum. Most of the work turned out to be everything around that. We had to choose defaults, write diagnostics that catch mistakes early, test the strange cases and measure the code the JIT actually runs, rather than the code we thought we were writing.
All of HCTI now runs on the package. Our .NET client uses it too, and the first thing that change did was delete the client's hand-written enum switches. If you want to try it, install FancyEnumGenerator from NuGet and start with the README and examples. If there is an enum pattern you would like it to handle, open an issue. I would love to hear about it.
