A Roslyn incremental source generator that turns a plain C# enum into a fast, allocation-conscious set of extension members — ToString, Parse/TryParse, flags handling, and arbitrary per-member field mappings — computed entirely at compile time.
- Less boilerplate. No more hand-written
switchstatements that map each member to a label, a database value or an icon, and a second one that maps it back, all kept in sync by hand. Declare the value on the member as an attribute and the generator writes both directions. Add a member and there's nocaseto forget. - Fast. Generated
ToStringFancy/TryParseFancyare plainswitchexpressions over the underlying primitive, with no reflection and no boxing. In the benchmarks on .NET 10:- Parsing a name takes 3.6 ns against
Enum.Parse's 46 ns on a 25-member enum, and 7.5 ns against 264 ns on a 200-member one. A miss costs the same as a hit. ToStringFancy()returns a string literal: under 1 ns and nothing allocated, whereToString()takes 6.9 ns and 24 B.- Parsing from a
[Description]takes 2.8 ns, against 6.1 µs and 3.4 KB for the usual reflection. - Nothing is built at runtime by default: no static caches and no first-call cost (Footprint).
- Parsing a name takes 3.6 ns against
- Works with public and internal enums, nested types,
[Flags]enums, and any underlying numeric type.
dotnet add package FancyEnumGeneratorThat's the only package reference you need — it bundles both the generator and the attribute types it reads.
using FancyEnumGenerator.Attributes;
[FancyEnum]
public enum Fruit
{
Unknown = 0,
Apple = 1,
Banana = 2,
Cherry = 3,
}That's it. You now have:
Fruit.Apple.ToStringFancy(); // "Apple"
Fruit.TryParseFancy("Banana", out var f); // true, f == Fruit.Banana
Fruit.Apple.IsUnknown; // false
((Fruit)7).IsUnknown; // true
Fruit.Unknown.IsUnknown; // true
Fruit.Values; // every non-Unknown member (Apple, Banana, Cherry), as a ReadOnlySpan[FancyEnum] enforces two rules by default (both can be relaxed — see Settings):
- Exactly one member named
Unknown(case-insensitive) with value0. Your never-fails fallback for parsing and out-of-range values. - Numeric values must be contiguous (0, 1, 2, 3, ...). Needed for the generator's fastest lookup paths.
Attach arbitrary per-member data with [FancyEnumMember]:
[FancyEnum]
public enum Fruit
{
Unknown = 0,
// SortOrder is independent of the numeric value: here Banana sorts first, without renumbering the enum.
[FancyEnumMember("Label", "apple")]
[FancyEnumMember<int>("SortOrder", 20)]
Apple = 1,
[FancyEnumMember("Label", "banana")]
[FancyEnumMember<int>("SortOrder", 10)]
Banana = 2,
}generates:
Fruit.Apple.Label; // "apple" (string)
Fruit.Apple.SortOrder; // 20 (int)
Fruit.Banana.SortOrder; // 10FancyEnumMember (no type argument) is shorthand for string values; FancyEnumMember<T> works for any attribute-legal type (bool, numeric types, char, string, enums, Type).
If a member doesn't set a field, [FancyEnumMemberMappingSettings] on the enum controls what happens:
[FancyEnum]
[FancyEnumMemberMappingSettings("Label", NotDefined = FancyEnumMemberFallbackOption.NameOfLower)]
[FancyEnumMemberMappingSettings<int>("SortOrder", NotDefined = -1)]
public enum Fruit
{
Unknown = 0,
Apple = 1, // Label falls back to "apple" (NameOfLower), SortOrder falls back to -1
}Settings available per field: NotDefined (fallback for a member that never set this field — Skip/NameOf/NameOfLower/NameOfUpper for the non-generic string attribute, or a literal default value for the generic <T> one), NotMatched (the fallback expression used when a runtime value doesn't match any known member), ReturnNullOnNotMatched / ThrowOnNotMatched, ParseFrom (also generate TryParseFrom_{Field}, parsing by this field's values instead of the member name), ParseCaseSensitive, CreateTryFormat, IncludeUtf8Value (also generate a ReadOnlySpan<byte>-returning {Field}Bytes property, string fields only). Each is described in the settings reference.
[Flags]
[FancyEnum]
public enum Permission
{
Unknown = 0,
Read = 1,
Write = 2,
Execute = 4,
[FancyEnumMemberSettings(ExcludeFromValues = true)]
All = Read | Write | Execute,
}(Permission.Read | Permission.Write).HasFlagFancy(Permission.Read), ListFlagMembers (decomposes a value into its atomic flags, .NET 8+), and ToStringFancy(separator: '|') all work out of the box. [FancyEnumMemberSettings(ExcludeFromValues = true)] keeps a composite value like All out of Values/AsSpan and out of the "not a single bit" warning — use it on any combined/derived value.
IsUnknown stays strict for flags, as for any enum: it's true for anything that isn't a declared member, including combinations like Read | Write. IsUnknownAndNotCombined is the flags-aware version: false for any combination of declared flags, and true only for Unknown itself or a value with a bit that isn't a declared flag (like (Permission)33). It's a single bit test. FromUnderlying follows the same rule for flags: Permission.FromUnderlying(3) returns Read | Write, and only a value with undeclared bits comes back as Unknown.
Members marked [Obsolete] are excluded from generation by default. Opt a specific enum back in with [FancyEnum(IncludeObsolete = true)], or flip the default for the whole assembly (see Settings).
The settings you're most likely to change:
| Setting | Default | What it does |
|---|---|---|
AllowNoUnknown |
false |
Don't require an Unknown = 0 member. (ParseOrDefault then replaces ParseOrUnknown.) |
AllowNonContiguous |
false (true for [Flags]) |
Allow gaps between the numeric values. |
ParseCaseSensitive |
true |
Whether parsing without an ignoreCase argument is case-sensitive. Field parsers inherit it. |
DefaultToStringBehavior |
NameOf |
What ToStringFancy() returns: the name, lower- or upper-cased, a field's value, or the member's [Description], [Display], [EnumMember] or [JsonStringEnumMemberName]. |
CreateByteParsing |
false |
Add UTF-8 (ReadOnlySpan<byte>) parse overloads. |
CreateTryFormat |
false |
Add TryFormat(Span<char>, out int), which formats into your own buffer. |
ValuesType |
Span |
What Values returns: a zero-copy ReadOnlySpan<T> (Span), a storable inline-array copy (InlineArray) or a cached IReadOnlyList<T> (StaticCollection). |
IncludeObsolete |
false |
Generate [Obsolete] members too; by default they're left out. |
Each can be set in three places. For each setting, the first of these that sets it wins:
- On the enum:
[FancyEnum(AllowNoUnknown = true)] - For the whole assembly:
[assembly: FancyEnumDefaults(ParseCaseSensitive = false)] - Repo-wide, as an MSBuild property named
FancyEnum+ the setting:<FancyEnumCreateByteParsing>true</FancyEnumCreateByteParsing>
Full settings reference: every
setting in detail, with its effects on the generated code, interactions, and diagnostics. It also covers the rest:
NoInlineArray, CreateIsValidPrefix, DefaultToStringCustomField, the assembly-only GenerateParseMethods and
UseGeneratedFileSuffix, and the settings for field mappings, members, and member sets.
Deeper dives live in docs/:
- Settings reference: every setting in detail.
- How it works: how the parsers pick a strategy (and the measurements behind each choice), what the rest of the generated code does, and how the generator stays incremental.
IsValidPrefixFancy: rejecting unknown names mid-stream, with a form-decoding example.- Benchmarks: full results against the BCL, NetEscapades.EnumGenerators and Enums.NET.
Instead of stacking several [FancyEnumMember]/[FancyEnumMember<T>] attributes on every member, define the shape once as a plain attribute class:
[FancyEnumMemberSet]
public sealed class FruitMetadataAttribute : Attribute
{
public string? Label { get; set; }
[FancyEnumMemberSetItem(Name = "SortOrder", DefaultValue = -1)]
public int Order { get; set; }
}and apply it once per member:
[FancyEnum]
public enum Fruit
{
Unknown = 0,
[FruitMetadata(Label = "apple", Order = 20)]
Apple = 1,
// No FruitMetadata at all - Label and SortOrder both fall back per their own settings.
Banana = 2,
}Fruit.Apple.Label and Fruit.Apple.SortOrder are generated exactly as if you'd written the equivalent [FancyEnumMember]s directly. A member that never applies the shape attribute at all behaves identically, per field, to one that applies it but omits that property — both fall through to DefaultValue/the field's normal fallback settings.
The enum still needs [FancyEnum]; using a member-set attribute on an enum without it reports HENUM016 (a warning), since nothing is generated for it.
Every public property counts, including inherited ones, and each must be settable by some member: its type has to be one C# allows as an attribute argument (a primitive, string, object, Type, or an enum — no int?, DateTime or arrays), and a read-only property needs a constructor parameter mapped to it. Anything else is HENUM015; mark it [FancyEnumMemberSetItem(Ignore = true)] if the class uses it for something else. Each member-set class is validated once, where it's declared, whether or not an enum uses it yet.
Per-property controls via [FancyEnumMemberSetItem]:
| Property | What it does |
|---|---|
Name |
Override the generated field name (defaults to the property's own name). |
Ignore |
Excludes the property entirely — it's not a mapped field (e.g. a property the shape uses for something else). |
DefaultValue |
Fallback value for a member that didn't set this property. Must match the property's own type exactly — a mismatch reports HENUM012 and the default is dropped, not silently miscompiled. |
NotDefined |
String properties only: fall back to the member's name (NameOf, NameOfLower, NameOfUpper) instead of a constant, as on [FancyEnumMemberMappingSettings]. DefaultValue wins if both are set. |
NotMatched |
Value returned when no mapping applies. Same type rule (and HENUM012) as DefaultValue. |
ReturnNullOnNotMatched, ThrowOnNotMatched, ParseFrom, ParseCaseSensitive, CreateTryFormat, IncludeUtf8Value |
Same meaning as on [FancyEnumMemberMappingSettings]. |
A [FancyEnumMemberSet] property doesn't have to be settable. A read-only property's value can only come from a constructor parameter:
[FancyEnumMemberSet]
public sealed class CssClassAttribute : Attribute
{
public string ClassName { get; }
public CssClassAttribute(string className) => ClassName = className;
}[CssClass("btn-primary")]
Apple = 1,The parameter className is matched to the property ClassName automatically (name, case-insensitive, and type). When a parameter's name doesn't line up, mark it explicitly:
public CssClassAttribute([FancyEnumConstructorMapping(nameof(ClassName))] string css) => ClassName = css;Every public constructor with parameters on a member-set shape is validated this way at compile time (HENUM013 if a parameter resolves to nothing), regardless of whether any enum currently uses that overload.
Important limitation: the generator reads the raw constructor argument exactly as written at the call site — it never actually runs the constructor (a source generator can't execute arbitrary code at compile time). If your constructor transforms the value before assigning it:
public OrderAttribute(int order) => Order = order + 1; // the generator sees the raw `order`, never `order + 1`...document that for whoever uses your shape. Constructors that do plain assignment keep the generated field matching what a reader would expect.
Each enum gets one generated class, {Enum}FancyEnumExtensions, and it's partial, so you can add your own members
to it. They then sit next to the generated ones and are reachable through the same using, with no extra import. Put the part in the enum's namespace, with the
same accessibility as the enum:
public static partial class FruitFancyEnumExtensions
{
extension(Fruit fruit)
{
public bool IsYellow => fruit == Fruit.Banana;
public string Shout => fruit.ToStringFancy().ToUpperInvariant(); // can build on the generated members
}
}Besides the extension members, the class holds compile-time constants (FruitFancyEnumExtensions.Length,
LongestCharLength, ...) for where C# requires a constant, such as const fields, attribute arguments and case
labels. They're the same values as Fruit.Length and friends, which as extension members can't be constants. Nothing
is generated under the conventional {Enum}Extensions name, so your own FruitExtensions class never clashes with
generated code. For a nested enum, the enclosing types' names are joined with _, as in
Outer_NestedFancyEnumExtensions.
For a value that isn't a compile-time constant:
[FancyEnumMember<IconName>("Icon", StaticMethodName = nameof(Icons.Apple), StaticMethodSource = typeof(Icons))]
Apple = 1,references a static readonly field or parameterless static method instead of a literal — the generated property reads/calls it directly. (Skips TryFormat/ParseFrom for that field, since those require a compile-time-known string value.)
| ID | Meaning |
|---|---|
| HENUM001 | Missing the required Unknown = 0 member (AllowNoUnknown to relax). |
| HENUM002 | Non-contiguous numeric values (AllowNonContiguous to relax). |
| HENUM003 | Invalid mapping: duplicate settings for a field, or a field mapped to two different return types. |
| HENUM004 | The same numeric value declared by two members. |
| HENUM005 | DefaultToStringBehavior = CustomFieldRequired but a member never mapped that field. |
| HENUM006 (warning) | Two members produce the same parser token; parsing picks one. |
| HENUM007 | The enum's containing type is generic or not accessible from its namespace — extensions can't be generated. |
| HENUM008 | CreateTryFormat requested but unavailable (non-string field, or a StaticMethodSource value). |
| HENUM009 | ParseFrom requested but unavailable (non-string field, a StaticMethodSource value, or no string values to parse). |
| HENUM010 (warning) | A [Flags] member's value isn't zero or a single bit — use ExcludeFromValues for intentional composites. |
| HENUM011 | IncludeUtf8Value requested but unavailable (non-string field, or a value computed at runtime). |
| HENUM012 | A FancyEnumMemberSetItem.DefaultValue or NotMatched doesn't match its property's type, or NotDefined is set on a non-string property; the value is dropped. |
| HENUM013 | A member-set constructor parameter doesn't resolve to any property; add FancyEnumConstructorMapping. |
| HENUM014 | A mapped field would generate a member whose name FancyEnum or System.Enum already uses (e.g. Length, Values, ToString), or that another field also generates. |
| HENUM015 | A member-set property can never be set: its type can't be an attribute argument, or it's read-only with no constructor parameter mapped to it. |
| HENUM016 (warning) | An enum uses a member-set attribute but has no [FancyEnum], so nothing is generated for it. |
| HENUM017 (warning) | An explicitly chosen ValuesType can't be generated for this target (Span on a wider-than-byte enum before .NET 7, InlineArray before .NET 8 or with NoInlineArray), so Values is left out there. |
The examples project has runnable, checked-in before/after: hand-written enums next to the exact code the generator produces for them (under examples/FancyEnumGenerator.Examples/Generated/), covering every feature above. LargeEnum.cs is a 90-member HttpHeader enum, parsed case-insensitively from text or UTF-8, if you want to see what the generated code looks like at scale.
A few highlights, from a full-precision BenchmarkDotNet run on .NET 10 (Apple M1 Max). The complete, unedited reports, and which libraries each one compares, are in docs/benchmarks.md.
| BCL | FancyEnum | NetEscapades | Enums.NET | |
|---|---|---|---|---|
ToString |
6.9 ns, 24 B | <1 ns | <1 ns | <1 ns |
| Parse, hit (25 members) | 46.0 ns | 3.6 ns | 36.3 ns | 14.9 ns |
| Parse, hit (200 members) | 263.7 ns | 7.5 ns | 227.4 ns | 14.2 ns |
| Parse, miss (200 members) | 485.5 ns | 7.3 ns | 351.3 ns | 13.1 ns |
| Parse, ignoring case | 56.6 ns | 8.4 ns | 47.5 ns | 17.1 ns |
Parse from [Description] |
6.1 µs, 3.4 KB (reflection) | 2.8 ns |
Nothing here allocates except where noted.
- No runtime state by default. Generated members are
switchexpressions over constants and string literals, andValuesis a span over data stored in your assembly, so an enum adds no static fields and no heap allocations, even on first use. The one exception is the opt-inValuesType = StaticCollection, which adds one array of the members, created once. - Code size, in exchange. The generated code is compiled into your assembly. In a release build that's about 6 KB
for a 25-member enum with default settings, about 10 KB with UTF-8 parsing,
TryFormatand a cached collection all on, and about 36 KB for a 200-member enum. Much of that is parsing: exact and case-insensitive lookups each get their own set ofswitchcases. On top of that comes about 1.5 KB of shared parsing helpers, once per assembly. The BCL and reflection-based libraries add no code to your assembly; instead they build their caches at runtime.
- The parser's approach, which switches on input length and then compares ASCII tokens packed into
ulongconstants (with upper-cased constants for case-insensitive matching), is inspired by StackExchange.Redis'sAsciiHash. - NetEscapades.EnumGenerators by Andrew Lock was the inspiration for generating fast enum helpers with a source generator in the first place, and is well worth a look.
FancyEnumGenerator is built and maintained by HTML/CSS to Image, an API for turning HTML/CSS or a URL into an image. We use it for the 150-plus enums across our own codebase. For the story behind it, and how the parsers got fast, see the blog post. Release notes and upgrade guides are in CHANGELOG.md.
MIT — see LICENSE.