Closed Type Hierarchies with records … NOT!

TL;DR: You cannot create closed type hierarchies with record types because of the generated protected copy constructor.

Erratum

Some time ago I wrote a post about forcing developers to deal with errors. In that post I introduced the following result types:

public abstract record CreationResult<T>
{
    // private Ctor to prevent outside code from adding another CreationResult type
    private CreationResult()
    {
    }

    // private to prevent outside code from pattern matching
    private sealed record Success(T Instance) : CreationResult<T>;
    private sealed record Failure(Error Error) : CreationResult<T>;

    public static implicit operator CreationResult<T>(T instance) => new Success(instance);

    // other implicit operators and Switch methods that are not relevant here
}

The entire post including the code assumed that CreationResult<T> was a closed type hierarchy, i. e. that it is impossible for a instance of CreationResult<T> to ever be of another type than Success or Failure.

This is false!

Why?

The choice of record may be convenient in terms of syntax and implicitly generated methods.
But unfortunately, one of those implicitly generated methods is a protected copy constructor.

Having a protected constructor opens up the type hierarchy to further inheritance, even from other assemblies.
Of course you have to supply an instance of CreationResult<T> to use it.
But since we have to be able to instantiate CreationResult<T> at some point, we will always have an at least internal means to create one.
If we put CreationResult<T> in a library instantiation needs to be even public.
In the above code example the implicit operators are used.
You could do it like this:

record YetAnotherResultType<T> : CreationResult<T>
{
    public YetAnotherResultType(T instance)
        : base(instance)
    {
    }
}

This is very straightforward and could even happen by accident.

If we removed the implicit operators and made Success and Failure instantiable by other means, e. g. by making them public, it would be a little harder:

record YetAnotherResultType<T> : CreationResult<T>
{
    public YetAnotherResultType(T instance)
        : base(new CreationResult<T>.Success(instance))
    {
    }
}


I admit this is a bit contrived and will probably not happen by accident.

How to close the type hierarchy

At this point, we could decide that this is unlikely enough and continue using record-based result types and just assume they will be closed.
Whoever dares to maliciously circumvent our obvious intention gets what they asked for.
This may make sense if you use specific result types for specific methods or interfaces, even if it costs us the syntactic sugar of implicit conversion.

If we want our type hierarchy to be truly closed, we do have to guard against such malicious abuse of the copy constructor.
This means we cannot use record at all, but have to resort to using class where we control all the code.
The price for this peace of mind is having to implement everything that record gives us for free ourselves: equality contracts, ToString(), GetHashCode().
This may be more appropriate when we use one single generic result type everywhere.

Conclusion

Watch out when using record types for convenience only.
Always think about what being a record entails.
When in doubt, use classes and pay the price.

1 Comment

Comments are closed