Like many other developers who cut their teeth during the .NET doldrums before it went open source and cross-platform, I had written off C# as a language that could be described as “necessary annoyance” at best. No matter how many times another developer told me, “.NET is actually good now! You should check it out! Have you heard of F#?” I was unable or unwilling to shift my mindset. However, after working on a project for the last year and a half that heavily employs C# and related .NET tooling, I think I’m beginning to come around.
This post isn’t a language review, but I’d like to highlight a C# feature that took me by surprise, saved our team a bunch of work, and allowed for a more elegant software solution than we initially thought possible: user-defined implicit type conversion.
User-defined Implicit Type Conversion: What is it?
Many languages can implicitly convert between different data types. For example, a large portion of the infamy that JavaScript has gained over the years comes from this feature. C# has limited whole-number-related implicit conversions. For example, the following code is valid and implicitly converts an int
into a long
:
long longNumber = 100; // This number is very long
int test2 = 1; // this number is only a test, do not use it
var test3 = test2 + longNumber; // The int and long combine to make a long
So that’s a very convenient feature when working with whole numbers almost all of the time. But what if I want to achieve this kind of behavior with any type in C#? Well, here’s how that might look:
public record BrandedLong
{
public readonly string Brand = "DomainSpecificLongNumberIdentifier";
// This number isn't just implied to be lengthy, you know it is because it's branded as such!
public long LongNumber { get; init; }
public static implicit operator BrandedLong(long id) => new BrandedLong { LongNumber = id };
}
The above example can be used to handle branded numbers, a pattern that we’ve talked about before on this blog, in a less annoying way. Now when you want to turn a long
into a BrandedLong
all you have to do is assign it as such:
var x = (BrandedLong)123; // Explicitly cast that number!
BrandedLong y = 123; // typed variable declaration works too
// Parameter type casting is a great usage for this!
void TestFunction(BrandedLong bl) { }
TestFunction(123);
This feature has some drawbacks. It can be confusing for a developer who is unfamiliar with the codebase to see numbers turn into class instances seemingly without any code. It can also lead to potential problems if the implementation of the conversion function is complex or behaves unintuitively. However, overall it can be a super powerful tool. Let me give a real example of how we used this in the project I’m currently working on.
Implicit Conversions Extended
My current project involves an extended team of 12-14 developers working in the same code base. Trust me, there are very good reasons for this! But, one consequence is that changes to the code should strive to have a limited blast radius, if possible, to reduce the risk of nasty merge conflicts. Implicit type conversions recently helped us achieve this in a big way. My pair developer and I needed to refactor some reporting features to allow for something that had previously been identifiable with a single integer ID to now be identifiable with either a single integer or a composite of two different integers. There are a ton of ways to go about this. However, we landed on a solution that allowed us to leave lots of existing code untouched due to user-defined implicit conversion. Here’s the new descriptor class we replaced long Id
with:
public abstract record ReportDescriptor
{
public static implicit operator ReportDescriptor(long id) => new IssueReportDescriptor { MigrationId = id };
public static implicit operator ReportDescriptor((long a, long b) pair) =>
new ComparisonReportDescriptor { MigrationAId = pair.a, MigrationBId = pair.b };
}
public record IssueReportDescriptor : ReportDescriptor
{
public long MigrationId { get; init; }
}
public record ComparisonReportDescriptor : ReportDescriptor
{
public long MigrationAId { get; init; }
public long MigrationBId { get; init; }
public override string ToString() => $"{MigrationAId}:{MigrationBId}";
}
Here’s the beauty of this solution. It allowed all the call sites to certain service methods to remain the same, because replacing the signature of the method with ReportDescriptor
allows a long
or a tuple of long
to be passed in, and then the service method can figure out how to handle things from there. This solution encapsulates all knowledge that there are two different types of report descriptors completely within the service. That means there’s no special handling for this stuff spread out in the application.
We were really happy with how this turned out, and I experienced a Grinch-like heart growth moment for C# in the process of completing this feature. It was a nice reminder that reaching for more advanced language features is a great way to stretch your knowledge and craft as a developer, and it helped us deliver a better solution more quickly.