Many thanks to the observant readers who pointed out that I missed a few things in last week’s post on C# semantic types. To round out our semantic type, we need to add a few additional features.

ToString()

Every .NET type has a ToString() method inherited from object. Unfortunately, the standard implementation is somewhat unhelpful, simply returning the fully qualified type name of the instance. (Though it is understandable why that’s the default implementation - at least it gives you more than just an empty string.)

Without a custom implementation of ToString(), we can’t use an instance directly in a string.Format() call, nor in the closely related C# 6 interpolated string without explicitly drilling into our Code property:

var prompt = $"Course #{course.Code.Code}";

A simple implementation of ToString() makes it easier for our users to concentrate on their goals, not on our CourseCode class.

Since our CourseCode contains only a single member, the implementation is straightforward:

public override string ToString() => Code;

With this complete, an interpolated string doesn’t need to drill down that last level:

var prompt = $"Course #{course.Code}";

If your semantic type is more complicated than our example, do consider implementing the IFormattable interface and its more complex ToString(String, IFormatProvider) so that your type can be more richly formatted.

Comparisons

Consider the CompareTo method already implemented:

public int CompareTo(CourseCode other)
    => string.Compare(Code, other?.Code,
        StringComparison.InvariantCultureIgnoreCase);

Given this, we can write a static Compare method:

public static int Compare(CourseCode left, CourseCode right)
    => ReferenceEquals(left, right)
        ? 0
        : left?.CompareTo(right) ?? 1;

This is one of our more complex methods, so it’s worth looking how it works. There are five cases to consider:

  • When both values are null - ReferenceEquals() will return true, so we return 0.
  • When both values are the same instance - ReferenceEquals() will return true, so we return 0.
  • When neither value is null - CompareTo() will do our comparison.
  • When right is null (and left is not) - CompareTo() will do our comparison.
  • When left is null (and right is not) - the Elvis operator will return null and we return 1.

With that out of the way, providing implementations of the comparison operators is straightforward:

public static bool operator <(CourseCode left, CourseCode right)
    => Compare(left, right) < 0;

public static bool operator <=(CourseCode left, CourseCode right)
    => Compare(left, right) <= 0;

public static bool operator >(CourseCode left, CourseCode right)
    => Compare(left, right) > 0;

public static bool operator >=(CourseCode left, CourseCode right)
    => Compare(left, right) >= 0;

Debugging

By default the debugger will use ToString() to display an instance. The attribute [DebuggerDisplay] gives you more control how your type is shown inside the debugger.

It can be helpful, when debugging, to be able to easily see a description of an instance or two - imagine looking at a list in the debugger and seeing every item as “{CourseCode}”.

[DebuggerDisplay("[CourseCode {Code}]")]
public sealed class CourseCode { ... }

A Base Class?

One correspondent asked “Why not implement all of these details in a base class?” and save all the work?

Much as this might seem to be a good idea, it has a fatal flaw: having a common base type subverts the original motivation for introducing semantic types: we want to ensure that different values are type incompatible so that the compiler will catch errors as early as possible.

Comments

blog comments powered by Disqus