blog post image
Andrew Lock avatar

Andrew Lock

~14 min read

Splitting the NetEscapades.EnumGenerators packages: the road to a stable release

Share on:

In this post I describe some of the significant restructuring to my source generator NuGet package NetEscapades.EnumGenerators which you can use to add fast methods for working with enums. I start by describing why the package exists and what you can use it for, then I describe what motivated the restructuring, and finally what the changes are and a call to action.

The tl;dr; is that there are now three different packages, and exactly which one is best for you depends on what you're trying to do. Check the section below or the project's README for details!

As an aside, I really want to give this package a stable 1.0.0 release shortly, as I think we've solved most of the corner cases that were bugging me. Which means if the new package structure doesn't work for you, now is the time to raise an issue, before it gets set in stone!

Why should you use an enum source generator?

NetEscapades.EnumGenerators was one of the first source generators I created using the incremental generator support introduced in .NET 6. I chose to create this package to work around an annoying characteristic of working with enums: some operations are surprisingly slow.

Note that while this has historically been true, this fact won't necessarily remain true forever. In fact, .NET 8+ provided a bunch of improvements to enum handling in the runtime.

As an example, let's say you have the following enum:

public enum Colour
{
    Red = 0,
    Blue = 1,
}

At some point, you want to print the name of a Color variable, so you create this helper method:

public void PrintColour(Colour colour)
{
    Console.WriteLine("You chose "+ colour.ToString()); // You chose Red
}

While this looks like it should be fast, it's really not. NetEscapades.EnumGenerators works by automatically generating an implementation that is fast. It generates a ToStringFast() method that looks something like this:

public static class ColourExtensions
{
    public string ToStringFast(this Colour colour)
        => colour switch
        {
            Colour.Red => nameof(Colour.Red),
            Colour.Blue => nameof(Colour.Blue),
            _ => colour.ToString(),
        }
    }
}

This simple switch statement checks for each of the known values of Colour and uses nameof to return the textual representation of the enum. If it's an unknown value, then it falls back to the built-in ToString() implementation to ensure correct handling of unknown values (for example this is valid C#: PrintColour((Colour)123)).

If we compare these two implementations using BenchmarkDotNet for a known colour, you can see how much faster ToStringFast() implementation is:

MethodFXMeanErrorStdDevRatioGen 0Allocated
ToStringnet48578.276 ns3.3109 ns3.0970 ns1.0000.045896 B
ToStringFastnet483.091 ns0.0567 ns0.0443 ns0.005--
ToStringnet6.017.985 ns0.1230 ns0.1151 ns1.0000.011524 B
ToStringFastnet6.00.121 ns0.0225 ns0.0199 ns0.007--
ToStringnet10.06.4389 ns0.1038 ns0.0971 ns1.0000.003824 B
ToStringFastnet10.00.0050 ns0.0202 ns0.0189 ns0.001--

Even though recent versions of .NET are way faster, the overall pattern hasn't changed: .NET is way faster than .NET Framework, and the ToStringFast() implementation is way faster than the built-in ToString(). Obviously your mileage may vary and the results will depend on the specific enum you're using, but in general, using the source generator should give you a free performance boost.

That's the basics of the package, for more details see the project's README. In the next section I describe how adding some new features managed to break users, and what we did in response.

Adding new features by adding to the marker attribute dll

In version 1.0.0-beta19 of NetEscapades.EnumGenerators I introduced a bunch of new features that had been long standing requests:

  • Support for disabling number parsing.
  • Support for automatically calling ToLowerInvariant() or ToUpperInvariant() on the serialized enum.

There wasn't really a technical reason I took so long to add these features. The problem was that I didn't want to add dozens of different overloads of Enum.Parse() or ToString() to accommodate all the different possible options. Similarly, I didn't want to add these all as different (IMO, ugly) additional extension methods.

I solved this issue in what I thought was a neat way. When you referenced the NetEscapades.EnumGenerators package, your library references the NetEscapades.EnumGenerators.Attributes.dll file that is shipped in the package:

Inside the NetEscapades.EnumGenerators 1.0.0-beta19 package

This is just one way to add "marker" attributes to a target application, and until recently it was the most reliable way. My epiphany was to realise that I could put other types in this dll too; including types that are part of the target app's "public" API.

So I added EnumParseOptions and SerializationOptions types to NetEscapades.EnumGenerators.Attributes.dll, and used these types to add "general, extensible" overloads for the generated Parse and ToString() methods, something like this:

public static partial class ColourExtensions
{
    // EnumParseOptions controls case sensitivity, number parsing, matching metadata attributes 
    public static Colour Parse(string? name, in EnumParseOptions options);

    // SerializationOptions controls case transforms and whether to use metadata attributes
    public static string ToString(Color valu, in SerializationOptions options)
}

This all seemed very neat. If we want to add more features, we can just extend the options objects, no need for new methods or anything. However, I inadvertently managed to break a bunch of users 🤦‍♂️

When new features break users…

Shortly after publishing the new version of the package, I received reports of users seeing the following error:

Error CS0012: The type 'EnumParseOptions' is defined in an assembly that is not referenced. You must add a reference to assembly 'NetEscapades.EnumGenerators.Attributes, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'. (184, 11)

The issue seemed pretty clear: the NetEscapades.EnumGenerators.Attributes.dll that contains the new options objects used in the API wasn't being referenced in the final application. As I said in my initial response:

I'm guessing that you're excluding assets where you reference the package, e.g. something like this?

<PackageReference Include="NetEscapades.EnumGenerators" Version="1.0.0-beta19" > PrivateAssets="all" />

or like this:

<PackageReference Include="NetEscapades.EnumGenerators" Version="1.0.0-beta19" ExcludeAssets="all" />

you can't do that, you need just a normal reference:

<PackageReference Include="NetEscapades.EnumGenerators" Version="1.0.0-beta19" />

But what I totally overlooked is that many people intentionally add references to source generators using this pattern:

For all our code generators, we list them in the Directory.Build.props at the solution level as such:

<PackageReference Include="NetEscapades.EnumGenerators"
                  ExcludeAssets="runtime"
                  PrivateAssets="all"
                  TreatAsUsed="true"/>

We do this so that every project has access to code generation, yet doesn't pass on the generator assemblies themselves as transitive dependencies. Our expectation is such that when we distribute our packages, we don't impose upon our consumers any unnecessary dependencies through transitive references (i.e. available in everything, pass it onto nothing).

Unfortunately for me, that makes total sense 😅

For some reason, I thought that source generators (and analyzers) didn't flow transitively to downstream projects. But that's not true, they do flow transitively. To make that clearer, imagine you have two projects, MyProject.Lib and MyProject.Web. MyProject.Web has a reference to MyProject.Lib, and MyProject.Lib adds a reference to NetEscapades.EnumGenerators. By default, MyProject.Web also gets a transitive reference to NetEscapades.EnumGenerators.

     [MyProject.Lib]          ←              [MyProject.Web]
            ↓                                      (↓)  
[NetEscapades.EnumGenerators]           [NetEscapades.EnumGenerators]

This is how "normal" project/package references work, but for some reason I thought source generators/analyzers were special. But it turns out no.

So why does this matter? Well it means that adding PrivateAssets="all" and ExcludeAssets="runtime" actually makes sense in a lot of cases, particularly if you're creating reusable libraries. If you're using a generator to generate code in a single package, and you don't want to force downstream consumers of your package to automatically be opted in to source generator, then these attributes can help, though they serve slightly different reasons:

  • PrivateAssets="all" ensures that any projects referencing this project don't get the dependency as a transitive dependency. In this example above, if MyProject.Lib set PrivateAssets="all", then MyProject.Web wouldn't get a transitive reference to NetEscapades.EnumGenerators.
  • ExcludeAssets="runtime" ensures that any runtime assets included in the package are not copied to the build output. In our case, that means the NetEscapades.EnumGenerators.Attributes.dll would not be copied to the build output.

So on that basis, it's clear that adding ExcludeAssets="runtime" was causing the missing reference error at run time. Previously the attribute only contained enum attributes, and those were elided by default anyway, so the "runtime dependencies" were actually just a benign spandrel that could be excluded without issue. But when I added the EnumParseOptions and SerializationOptions, suddenly there was a hard dependency and things went boom 💥

The solution: more packages

Now, the "easy" fix would be to just tell users they're holding it wrong, and to just not use ExcludeAssets="runtime" but there were some very reasonable explanations that users had for not wanting to do this.

One of the most compelling reasons was that the NetEscapades.EnumGenerators source generator is entirely an "implementation detail". Forcing additional downstream runtime dependencies on consumers just to be "allowed" to use the source generator doesn't feel right. Especially if you're not even using the EnumParseOptions or SerializationOptions overloads!

After some various discussion and back and forth, we settled on two key changes:

  • Move the "runtime dependencies" assembly (which currently only contains EnumParseOptions and SerializationOptions) into a separate package, NetEscapades.EnumGenerators.RuntimeDependencies.
  • If the EnumParseOptions types are available in the compilation (because you added a reference to the runtime dependencies package) then the source generator uses the EnumParseOptions and SerializationOptions types in its public API. However, if they're not available, it emits enum-specific versions of these types instead, which avoids the need to add the runtime dependencies package. More on this shortly!

In addition, to make onboarding easier, the actual source generator was split into a completely separate package, NetEscapades.EnumGenerators.Generator, and _NetEscapades.EnumGenerators becomes a metapackage instead:

NetEscapades.EnumGenerators
  |____NetEscapades.EnumGenerators.Generators
  |____NetEscapades.EnumGenerators.RuntimeDependencies

As discussed, these packages provide the following:

  • NetEscapades.EnumGenerators is a meta package for easy install.
  • NetEscapades.EnumGenerators.Generators contains the source generator itself.
  • NetEscapades.EnumGenerators.RuntimeDependencies contains dependencies that need to be referenced at runtime by the generated code.

The default approach is to reference the meta-package in your project. The runtime dependencies and generator packages will then flow transitively to any project that references yours, and the generator will run in those projects by default. But by splitting these packages up, we get more flexibility.

Avoiding runtime dependencies

As already discussed, in some cases you may not want these dependencies to flow to other projects, such as when you're using NetEscapades.EnumGenerators internally in your own library. In this scenario, you can take the following approach:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <!-- 👇 Add the generator package with PrivateAssets -->
  <PackageReference Include="NetEscapades.EnumGenerators.Generators" Version="1.0.0-beta21" PrivateAssets="All"/>

  <!-- Optionally add the runtime dependencies package -->
  <PackageReference Include="NetEscapades.EnumGenerators.RuntimeDependencies" Version="1.0.0-beta21" />
</Project>

The NetEscapades.EnumGenerators.RuntimeDependencies package is a "normal" dependency that contains the EnumParseOptions, SerializationOptions, and SerializationTransform types:

namespace NetEscapades.EnumGenerators;

/// <summary>
/// Defines the options use when parsing enums using members provided by NetEscapades.EnumGenerator.
/// </summary>
public readonly struct EnumParseOptions { }

/// <summary>
/// Options to apply when calling <c>ToStringFast</c> on an enum.
/// </summary>
public readonly struct SerializationOptions

/// <summary>
/// Transform to apply when calling <c>ToStringFast</c>
/// </summary>
public enum SerializationTransform

However, if you don't add a reference to the NetEscapades.EnumGenerators.RuntimeDependencies package, the source generator creates "nested" versions of the dependencies in each generated extension method instead of using the "global" versions:

namespace SomeNameSpace;

public static partial class MyEnumExtensions
{
    // ... generated members

    // The runtime dependencies are generated as nested types instead
    public readonly struct EnumParseOptions { }
    public readonly struct SerializationOptions
    public enum SerializationTransform
}

Generating the runtime dependencies as nested types like this has both upsides and downsides:

  • It avoids placing downstream dependency requirements on consumers of your library.
  • You can still use these additional overloads internally, even without having additional runtime dependencies
  • It makes consuming the APIs that use the runtime dependencies more verbose.

To make that last point concrete, if you add a reference to the RuntimeDependencies package, your code might look something like this:

// parsing
string serialized = "Red"
Color value = ColourExtensions.Parse(
    serialized, new EnumParseOptions(enableNumberParsing: false));

// serialization
var result = value.ToStringFast(
    new SerializationOptions(transform: SerializationTransform.LowerInvariant));

In contrast, if you omit the runtime dependencies, you have to use the full (nested) type names:

// parsing
string serialized = "Red"
Color value = ColourExtensions.Parse(
    serialized, new ColourExtensions.EnumParseOptions(enableNumberParsing: false));

// serialization
var result = value.ToStringFast(
    new ColourExtensions.SerializationOptions(transform: ColourExtensions.SerializationTransform.LowerInvariant));

which, you know, is pretty ugly. But it does avoid those runtime dependencies, and solves the users problems, so it's what is available today!

Choosing the correct packages for your scenario

So where does that leave us?

In general, for simplicity, if you're creating an app of some sort I recommend just using a "normal" package reference to NetEscapades.EnumGenerators (and thereby implicitly using NetEscapades.EnumGenerators.RuntimeDependencies). This particularly makes sense when you are the primary consumer of the extension methods, or where you don't mind if consumers end up referencing the generator package:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <PackageReference Include="NetEscapades.EnumGenerators" Version="1.0.0-beta21" />
</Project>

This "default" scenario also gets the best experience in terms of the optional "usage analyzers", which flag places that you should consider calling ToStringFast().

In contrast, if you are producing a reusable library and don't want any runtime dependencies to be exposed to consumers, I recommend using NetEscapades.EnumGenerators.Generators and setting PrivateAssets=All and ExcludeAssets="runtime".

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <PackageReference Include="NetEscapades.EnumGenerators.Generators" Version="1.0.0-beta21" PrivateAssets="All" ExcludeAssets="runtime" />
</Project>

The final option is to reference NetEscapades.EnumGenerators.Generators and set PrivateAssets=All and ExcludeAssets="runtime" (to avoid it being referenced transitively), but then also reference NetEscapades.EnumGenerators.RuntimeDependencies, to produce easier-to consume APIs.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <PackageReference Include="NetEscapades.EnumGenerators.Generators" Version="1.0.0-beta21" PrivateAssets="All" ExcludeAssets="runtime"/>
  <PackageReference Include="NetEscapades.EnumGenerators.RuntimeDependencies" Version="1.0.0-beta21" />
</Project>

⚠️ When using the NetEscapades.EnumGenerators metapackage, it's important you don't set PrivateAssets=All. If you want to use PrivateAssets=All or ExcludeAssets="runtime" use NetEscapades.EnumGenerators.Generators for this scenario.

All of which means the package now supports many different scenarios, though with the associated complexity. All of which leads me to the final part of this post: how close are we to a stable release?

Open questions before a stable release

I've been very hesitant to put out a stable 1.0.0 release for the package, mostly because I don't really intend there to be a 2.0.0 unless there's a very good reason. The reason being is that I want consumers of the package to not have to worry about things breaking underneath them.

I have a separate rant about how I feel many NuGet library authors have misunderstood the increased speed of the .NET ecosystem in recent years as a license to churn out major versions with endless breaking changes. But that's for a completely different post😅

In terms of features, I'm pretty happy with where the generator is today compared to even a few months ago:

  • Optional automatic support for System.Memory if it's available.
  • Optional usage analyzers to encourage using the extension methods where possible.
  • The extensible EnumParseOptions and SerializationOptions discussed in this post.
  • Catering to "internal" usages of the generator (where you don't want downstream consumers to be aware you're using it).
  • Support for enums defined in other libraries (including BCL enums)

Nevertheless, there are a few things I'm not entirely happy with. For example:

  • The usage analyzers are IMO pretty cool, should they just be enabled as warnings by default?
    • Presumably, if you're adding this package, you want to preferentially use them, which would suggest yes, enable them.
    • But on the other hand, that would potentially be a big breaking change and annoy a bunch of people that just have a targeted usage
    • However they could just disable them in that case.
    • Unfortunately, you need to "preserve attribute usages" if you want the analyzers to work in "downstream" projects and ensure you don't exclude runtime assets, but that's maybe just a limitation we'll have to live with s🤷‍♂️
  • The nested runtime dependency types are pretty ugly
    • Should they just be omitted/made private implementation details entirely?
    • My concern is that while they're a neat solution to a problem, maybe they should just be omitted (or made private, depending on complexity) to reduce confusion. The onboarding story becomes easier in this scenario: "add the NetEscapades.EnumGenerators.RuntimeDependencies package and get these extra features".
  • You can't use "target-typed" new on the EnumParseOptions and SerializationOptions overloads due to ambiguity with existing methods (shown below)
    • This is really annoying, but I think the only way to "solve" it is to remove the overloads that contain the same number of parameters, which is again, pretty disruptive, and those methods are there for a reason (convenience)!
    • The biggest concern is EnumExtensions.Parse(string name, bool ignoreCase), as that bool is more convenient than using EnumExtensions.Parse(string name, EnumParseOptions options) if you just want to ignore case.
    • But maybe it's worth seriously entertaining making that break sooner rather than later.

Screenshot showing ambiguous invocation error in Rider

Anyway, those are my current thoughts with the library. I need to figure out the answer to these questions before pushing out the 1.0.0, but I'd love for feedback from any users of the package that would be impacted by any of the changes above. Let me know your thoughts here or in the issue on Github. And hopefully, hopefully, we can get a stable version soon 😅

In the mean time, do try out the latest version, 1.0.0-beta21, read the package notes, and let me know about any issues or thoughts. There's extra goodness in there I haven't talked about in this post, so give it a try!

Summary

In this post I described the recent architectural to the NetEscapades.EnumGenerators package (which is now a metapackage) to support more scenarios. If you're a "standard" user of the package, there's nothing to worry about, you can simply add a reference to NetEscapades.EnumGenerators and everything should work smoothly.

However, if you want to control how the package flows transitively to consumers of your project then you may want to reference NetEscapades.EnumGenerators.Generators directly, and optionally add the NetEscapades.EnumGenerators.RuntimeDependencies package so you can use cleaner APIs.

I'd love any feedback you have about these recent changes; whether they're too confusing, if you can't work out which path you should take, or if you have any thoughts about the specific points I raised above. Just drop a comment here or in the issue on Github.

  • Buy Me A Coffee
  • Donate with PayPal
Andrew Lock | .Net Escapades
Want an email when
there's new posts?