Non-default value types and type invariants

In this post, we'll look at non-defaultable value types and type invariants. At the time of writing, neither of these things exist in C#. We'll also look at a .NET library that I recently did named Vogen which combines source generation and code analysers to help bridge the gap while we wait for these things to come to C#.

Non defaultable value types #

Non-defaultable value types are similar in concept to Nullable Reference Types (NRT) which were introduced in C# 8. For NRTs, static analysis was introduced which emits warnings when dereferencing potentially null objects. Similar analysis is required for dereferencing potentially default values.

For reference types, the analyser warns us about unitialised fields and variables. But it doesn't warn us for values types: if T is a struct, then default(T) means all members are 0 or null and the compiler won't warn us before we attempt to use them.

This is demonstrated in the following code. With nullability enabled, the compiler only reports on the issue in ContainerClass:

public struct ContainerStruct {
public string Value { get; }
}

public class ContainerClass {
public string Value { get; } // warning CS8618: Non-nullable property 'Value'
}

public static void Main() {
Console.WriteLine(new ContainerClass().Value.ToUpper());
Console.WriteLine(new ContainerStruct().Value.ToUpper());
}

Things are the same with the new record structs which were introduced in C# 10:

public record struct Foo(string Value);

public static void Main() {
Console.WriteLine(new Foo().Value.ToUpper());
}

The above code is clearly dangerous, but NRT analysis can't warn us about it.

A language proposal was created in 2017 (long before NRTs and records), which proposed that flow analysis is needed for 'defaultability' checking for value types (just as we know have flow analysis for 'nullability'). By checking for - and disallowing - defaulting of value types, we're essentially creating Type Invariants.

Type Invariants #

A Type Invariant constrains the state stored in an object. Methods of the class should preserve the invariant. In other words, if we create an instance of a type with a valid value, then no matter what methods we call on that instance, the value (state) it contains will always remain valid.
This is easier if the type is immutable; that is, we create an instance, and we do not change the state of that instance.

But type invariance, even for immutable types, still relies on having certain initialisation. The example used in the language proposal from 2017 has this example of a constrained value (remember, this was in the pre-records era of C#):

struct PositiveInt(int Value) requires Value > 0;

But, a mistakenly placed default(PositiveInt) blows this out of the water!

As mentioned in the discussions on the language proposal, the flow analysis for non-defaultable value types will be extremely difficult to implement. The following are some examples.

Given a record struct CustomerId(int Value), it should be easy enough for the analyser to spot:

var id = default(CustomerId);

It would be slightly trickier to find something like this:

public CustomerId GetCustomerId() => default;

But it would be much harder to find things like:

CustomerId[] CustomerIds[] = new CustomerId[10];

or

public class Foo<T> where T : struct
{
public T Get() => default;
}

Foo<CustomerId> f = new Foo<CustomerId>();
var c = f.Get();

As you can see, some of the scenarios are easy to spot, and some are extremely difficult, hence, nearly 5 years later, the non-defaultable value types feature still doesn't exist in C#.

Invariant Records #

In the language proposal for non-defaultable value types, there is this example:

struct PositiveInt(int Value) requires Value > 0;

This specifies the invariant at the type level, and in the language itself rather using than the now defunct Code Contracts.

Combing non-defaultable value types and type invariants, I submitted a new language proposal named Invariant Records.

Here's an example of what I imagine they'd look like:

public record struct Celsius(invariant float Value) {
private static void Validate(float Value) => Invariant.Ensure(Value >= -273, "cannot be less than absolute zero");
}

The new invariant modifier on the parameter says to the compiler to generate a call to the corresponding Validate method.

So, with these types, it's impossible to create a default one, and it's impossible to create one with an invalid value. And they're immutable, so if you create a valid one, it'll always be valid.

One comment that I received in the proposal said that the language team decided that validation in records should not be part of the language and is better suited through external means like source generators. This struck a chord with me as I've recently implemented a source generator named Vogen which does something quite similar.

Vogen #

Vogen (Value Object Generator) is source generator and code analyzer. It generates value objects, which are essentially just simple wrappers over primitive types. It helps you avoid Primitive Obsession and it helps you avoid common situations where you end up with invalid data in your programs. The code it generates is almost identical to records.

I've written previously about Primitive Obsession. That article referenced my previous open-source implementation of a Value Objects library. The trouble with that library was that it used inheritance and so everything had to be class, and so this caused memory pressure and didn't really fit well in situations where your primitive was a value type.

The goal of Vogen was to generate types (value types or reference types) that have very similar performance compared to using primitives directly. For instance, if an int represents a customer ID, then there's no overhead in using a CustomerId value object to represent it.

An example is:

[ValueObject(typeof(int))]
public readonly partial struct CustomerId {
private static Validation Validate(in int value) =>
value > 0 ? Validation.Ok : Validation.Invalid("must be greater than zero");

The source generator in Vogen recognises the ValueObject attribute and generates code for equality etc. But, unlike a record struct, the only way to create instances of these value objects is through the factory method named From:

var c1 = CustomerId.From(123); // OK
var c2 = CustomerId.From(-666); // exception!

So, here, a customer ID is immutable and invariant. As mentioned above, to achieve this, we should never allow defaulting, and we should always validate the data used to create the instance.
As you can see in the example above, a Customer ID should be a positive number. It blows up with an exception if we create one with a negative number.

One of the most valuable aspects of Vogen are the analysers. These go some of the way in helping to avoid accidentally creating default values. For example, the analyser will spot issues when you declare a value object:

[ValueObject(typeof(int))]
public partial struct CustomerId {
// Vogen already generates this as a private constructor to that you can't use it:
// error CS0111: Type 'CustomerId' already defines a member called 'CustomerId' with the same parameter type
public CustomerId() { }

// error VOG008: Cannot have user defined constructors, please use the From method for creation.
public CustomerId(int value) { }

... and it will spot issues when creating or consuming value objects:

// catches object creation expressions
var c = new CustomerId(); // error VOG010: Type 'CustomerId' cannot be constructed with 'new' as it is prohibited
CustomerId c = default; // error VOG009: Type 'CustomerId' cannot be constructed with default as it is prohibited.
var c = default(CustomerId); // error VOG009: Type 'CustomerId' cannot be constructed with default as it is prohibited.
var c = GetCustomerId(); // error VOG010: Type 'CustomerId' cannot be constructed with 'new' as it is prohibited

// catches lambda expressions
Func<CustomerId> f = () => default; // error VOG009: Type 'CustomerId' cannot be constructed with default as it is prohibited.

// catches method / local function return expressions
CustomerId GetCustomerId() => default; // error VOG009: Type 'CustomerId' cannot be constructed with default as it is prohibited.
CustomerId GetCustomerId() => new CustomerId(); // error VOG010: Type 'CustomerId' cannot be constructed with 'new' as it is prohibited
CustomerId GetCustomerId() => new(); // error VOG010: Type 'CustomerId' cannot be constructed with 'new' as it is prohibited

// catches argument / parameter expressions
Task<CustomerId> t = Task.FromResult<CustomerId>(new()); // error VOG010: Type 'CustomerId' cannot be constructed with 'new' as it is prohibited

void Process(CustomerId customerId = default) { } // error VOG009: Type 'CustomerId' cannot be constructed with default as it is prohibited.

Some of the things it won't pick up are:

Foo<CustomerId> f = new Foo<CustomerId>();

public class Foo<T> where T : struct {
public T Get() => default;
}

... and

var customerIds = new CustomerId[10];

In these situations, even though we don't get an analysis / compilation error, we do get a runtime error when attempting to access an instance that hasn't been initialised.

Summary #

We've seen that C# is currently lacking flow analysis around defaultable value types and that this impacts the flow analysis around Nullable Reference Types.
The missing flow analysis for values means that it's difficult to guarantee invariant value types.
We've also seen that in-built validation has been considered in C# but that the language team have decided that validation is better suited for source generators to handle.
And finally, we've seen how Vogen can help by enforcing validation constraints and help go some way with the analysis while we wait for the non-default value types feature to come along in C#.

πŸ™πŸ™πŸ™

Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated πŸ’–! For feedback, please πŸ¦‹ ping me on Bluesky! πŸ¦‹

Leave a comment

Comments are moderated, so there may be a short delays before you see it.

1 comments on this page

  • Dalibor ČarapiΔ‡

    commented

    I was not aware that null checking ignores structs with reference members. Do you perhaps have any links which explain this behaviour? I could not find anything on MS docs.
    Thank you.

Published