Gotcha with C# 9 Record inheritance and ToString()

TL;DR: On record types, ToString() and other compiler-synthesized methods cannot be overridden up the inheritance hierarchy in parent records, but must be overridden in the concrete record.

I was excited about finally being able to use record types in C# 9, because they would make using the type system for domain modelling much simpler. With all the goodness of compiler-synthesized Equals, ToString, IEquatable etc., and especially with expressions, many of my home-grown helpers (like DeepEquals or With) are now obsolete for good, at least for projects able to use C# 9.

I also expected to be able to greatly simplify the KindOf<T> base class in my domain primitives library (see my posts about domain primitives starting with My Take On Domain Primitives). After all, a record is a reference type with value semantics. In addition, it would give us IEquatable for free, which on KindOf requires an additional non-foolproof type parameter and is non-trivial in general (see Domain Primitives I: easily declaring domain primitives). I started out with the simplest record declaration that could possibly fulfill all my requirements:

public abstract record KindOfRecordTooSimple<T>(T Value)
{
    public static implicit operator T(KindOfRecordTooSimple<T> kindOf) => kindOf.Value;
}

The behavior of KindOfRecordTooSimple differs from the one of the original KindOf in two ways. First, it does not throw an ArgumentNullException when Value is null. Second, the string representation of the derived domain primitives will not be the one of the underlying value, but rather something like "MyPrimitive { Value = Foo }".

The first difference is easily remedied. But surprisingly, the second one will turn out to be insurmountable. Even if we add some boilerplate to check for null and override ToString, the string representation of derived classes will still not be the one of the underlying value.

abstract record KindOfRecord<T>
{
    protected KindOfRecord(T value) => Value = value ?? throw new ArgumentNullException(nameof(value));

    public T Value { get; }

    public override string ToString() => Value.ToString();

    public static implicit operator T(KindOfRecord<T> kindOf) => kindOf.Value;
}

record FirstName : KindOfRecord<string>
{
    public FirstName(string value)
        : base(value)
    {
    }
}

// this assertion will fail, the actual output of ToString is "FirstName { Value = John }"
new FirstName("John").ToString().Should().Be("John");

What gives?

It turns out that the documentation says

The compiler synthesizes two methods that support printed output: a ToString() override, and PrintMembers

and by that it means that the ToString override is generated for each record type individually and independently of inheritance. The override in the base record could just as well not be there.

As innocuous as that sounds — after all, it is only ToString, right? — that is a dealbreaker for records as base classes for my domain primitives. I try to make my domain primitives as transparent and unintrusive as possible. This requires the string representation to be the one of the underlying value. Unsuspecting developers must not be surprised when using domain primitives with libraries calling ToString (think WPF, Blazor etc.) or interpolated strings. Domain primitive type information should only be shown in the debugger.

In conclusion, records cannot be used to simplify my domain primitive base class because of the ToString gotcha. This is not too bad because the base class will only be written once anyway and it makes no difference to the actual domain primitive type declarations. But I am still a bit disappointed.

Nonetheless, I am looking forward to using records in many other places in my ongoing quest to increase code expressiveness and reduce boilerplate.

1 Comment

Comments are closed