C# 9 records validation

Viewed 5586

With the new record type of C# 9, how is it possible to inject a custom parameter validation/ null check/ etc during the construction of the object without having to re-write the entire constructor?

Something similar to this:

record Person(Guid Id, string FirstName, string LastName, int Age)
{
    override void Validate()
    {
        if(FirstName == null)
            throw new ArgumentException("Argument cannot be null.", nameof(FirstName));
        if(LastName == null)
            throw new ArgumentException("Argument cannot be null.", nameof(LastName));
        if(Age < 0)
            throw new ArgumentException("Argument cannot be negative.", nameof(Age));
    }
}
6 Answers

You can validate the property during initialization:

record Person(Guid Id, string FirstName, string LastName, int Age)
{
    public string FirstName {get;} = FirstName ?? throw new ArgumentException("Argument cannot be null.", nameof(FirstName));
    public string LastName{get;} = LastName ?? throw new ArgumentException("Argument cannot be null.", nameof(LastName));
    public int Age{get;} = Age >= 0 ? Age : throw new ArgumentException("Argument cannot be negative.", nameof(Age));
}

https://sharplab.io/#gist:5bfbe07fd5382dc2fb38ad7f407a3836

I'm late to the party, but this might still help someone...

There's actually a simple solution (but please read the warning below before using it). Define a base record type like this:

public abstract record RecordWithValidation
{
    protected RecordWithValidation()
    {
        Validate();
    }

    protected virtual void Validate()
    {
    }
}

And make your actual record inherit RecordWithValidation and override Validate:

record Person(Guid Id, string FirstName, string LastName, int Age) : RecordWithValidation
{
    protected override void Validate()
    {
        if (FirstName == null)
            throw new ArgumentException("Argument cannot be null.", nameof(FirstName));
        if (LastName == null)
            throw new ArgumentException("Argument cannot be null.", nameof(LastName));
        if (Age < 0)
            throw new ArgumentException("Argument cannot be negative.", nameof(Age));
    }
}

As you can see, it's almost exactly the OP's code. It's simple, and it works.

However, be very careful if you use this: it will only work with properties defined with the "positional record" syntax (a.k.a. "primary constructor").

The reason for this is that I'm doing something "bad" here: I'm calling a virtual method from the base type's constructor. This is usually discouraged, because the base type's constructor runs before the derived type's constructor, so the derived type might not be fully initialized, so the overridden method might not work correctly.

But for positional records, things don't happen in that order: positional properties are initialized first, then the base type's constructor is called. So when the Validate method is called, the properties are already initialized, so it works as expected.

If you were to change the Person record to have an explicit constructor (or init-only properties and no constructor), the call to Validate would happen before the properties are set, so it would fail.

EDIT: another annoying limitation of this approach is that it won't work with with (e.g. person with { Age = 42 }). This uses a different (generated) constructor, which doesn't call Validate...

The following achieves it too and is much shorter (and imo also clearer):

record Person (string FirstName, string LastName, int Age, Guid Id)
{
    private bool _dummy = Check.StringArg(FirstName)
        && Check.StringArg(LastName) && Check.IntArg(Age);

    internal static class Check
    {
        static internal bool StringArg(string s) {
            if (s == "" || s == null) 
                throw new ArgumentException("Argument cannot be null or empty");
            else return true;
        }

        static internal bool IntArg(int a) {
            if (a < 0)
                throw new ArgumentException("Argument cannot be negative");
            else return true;
        }
    }
}

If only there was a way to get rid of the dummy variable.

If you can live without the positional constructor, you can have your validation done in the init portion of each property which requires it:

record Person
{
    private readonly string _firstName;
    private readonly string _lastName;
    private readonly int _age;
    
    public Guid Id { get; init; }
    
    public string FirstName
    {
        get => _firstName;
        init => _firstName = (value ?? throw new ArgumentException("Argument cannot be null.", nameof(value)));
    }
    
    public string LastName
    {
        get => _lastName;
        init => _lastName = (value ?? throw new ArgumentException("Argument cannot be null.", nameof(value)));
    }
    
    public int Age
    {
        get => _age;
        init =>
        {
            if (value < 0)
            {
                throw new ArgumentException("Argument cannot be negative.", nameof(value));
            }
            _age = value;
        }
    }
}

Otherwise, you will need to create a custom constructor as mentioned in a comment above.

(As an aside, consider using ArgumentNullException and ArgumentOutOfRangeException instead of ArgumentException. These inherit from ArgumentException, but are more specific as to the type of error which occurred.)

(Source)

The other answers here are all very awesome, however, neither cover the with operator, as far as I can tell. I need to make sure we cannot enter an invalid state on our domain model, and we like to use the records where applicable. I stumbled upon this question while looking for the best solution for that. I've created a bunch of tests for different scenarios and solutions, however, most fail with the with expression. The variants I tried can be found here: https://gist.github.com/C0DK/d9e8b99deca92a3a07b3a82ba4a6c4f8

The solution I ended up going with was:

public record Foo(int Value)
{
    private readonly int _value = GetValidatedValue(Value);

    public int Value
    {
        get => _value;
        init => _value = GetValidatedValue(value);
    }

    private static int GetValidatedValue(int value)
    {
        if (value < 0) throw new Exception();
        return value;
    }
}

You sadly currently need both to handle both ways of updating/creating a record.

record Person([Required] Guid Id, [Required] string FirstName, [Required] string LastName, int Age);
Related