C# Nullable: Make nullchecking dependent on another property/variable
Asked Answered
D

2

14

I have just enabled null checking in my .net core 3.1 project.

The problem is that I have a response class

public class DecryptResponse
{
    public DecryptStatus Status { get; set; }

    //This is the attribute in question
    [NotNullWhen(Status==DecryptStatus.Ok)]
    public Stream? Stream { get; set; }

    public string? ErrorMessage { get; set; }
}

public enum DecryptStatus
{
    Ok,
    InvalidData,
    KeyChecksumFailure,
    NoData,
    UnhandledError
}

The above is used in this situation where the Verify method does not allow nulls.

But I know that the stream is not null since DecryptStatus==Ok

if (decryptResponse.Status != DecryptStatus.Ok)
    return (decryptResponse, null);

var verifyResponse = Verify(customerId, decryptResponse.Stream);
return (decryptResponse, verifyResponse);

Are there any tags that allows for this logic or does it need a major rewrite of the code?

Dopester answered 28/5, 2020 at 13:25 Comment(1)
NotNullWhen works only with method parameters, with property you can use NotNull attirbute. Or convert it to method and use something NotNullIfNotNull. You can find some ideas in this article Try out Nullable Reference TypesSympetalous
A
29

For .NET 5 and later: Use the new MemberNotNullWhen attribute.

  • The MemberNotNullWhenAttribute type was introduced in .NET 5.0 and C# 9.0.

    • (C# 9.0 also introduced init properties, which are useful for optional properties in immutable types without needing extraneous constructor parameters).
  • Use MemberNotNullWhen by applying it to any Boolean/bool property where its true/false value asserts that certain fields and properties will be "notnull" when that property is true or false (though you cannot yet directly assert that a property will be null based on a property)

  • When a single bool property indicates multiple properties are null or notnull then you can apply multiple [MemberNotNullWhen] attributes - or you can use the params String[] attribute ctor.

  • You might notice that your Status property is not bool - which means you will need to add a new property that adapts the Status property to a bool value for use with [MemberNotNullWhen].

...like so:

public class DecryptResponse
{
    public DecryptStatus Status { get; init; }

    [MemberNotNullWhen( returnValue: true , nameof(DecryptResponse.Stream))]
    [MemberNotNullWhen( returnValue: false, nameof(DecryptResponse.ErrorMessage))]
    private Boolean StatusIsOK => this.Status == DecryptStatus.Ok;

    public Stream? Stream { get; init; }

    public string? ErrorMessage { get; init; }
}

Of course there's a huge gaping hole with this approach: there's no way for the compiler to verify that Status, Stream, and ErrorMessage are set correctly. There is nothing stopping your program from doing return new DecryptResponse(); without setting any properties. That means the object is in an invalid state.

You might think that's not a problem, but if you need to keep on adding or removing new properties to a class eventually you'll get careless and forget to set a required property you need and then your program explodes.

A better implementation of DecryptResponse would use two separate constructors for the 2 mutually-exclusive valid states, like so:

public class DecryptResponse
{
    public DecryptResponse( Stream stream )
    {
        this.Status = DecryptStatus.OK;
        this.Stream = stream ?? throw new ArgumentNullException(nameof(stream));
        this.ErrorMessage = null;
    }

    public DecryptResponse( DecryptStatus error, String errorMessage )
    {
        if( error == DecryptStatus.OK ) throw new ArgumentException( paramName: nameof(error), message: "Value cannot be 'OK'." );
        
        this.Status       = error;
        this.Stream       = null;
        this.ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage ));
    }

    public DecryptStatus Status { get; }

    [MemberNotNullWhen( returnValue: true , nameof(DecryptResponse.Stream))]
    [MemberNotNullWhen( returnValue: false, nameof(DecryptResponse.ErrorMessage))]
    private Boolean StatusIsOK => this.Status == DecryptStatus.Ok;

    public Stream? Stream { get; }

    public String? ErrorMessage { get; }
}

And then used like so:

DecryptResponse response = Decrypt( ... );
if( response.StatusIsOK )
{
    DoSomethingWithStream( response.Stream ); // OK! The compiler "knows" that `response.Stream` is not `null` here.
}
else
{
     ShowErrorMessage( response.ErrorMessage ); // ditto
}

Long-answer (for writing better classes in general):

The alternative to updating to .NET 5 + C# 9, and to avoid the invalid state problems described above is to use a better class design which makes invalid states unrepresentable.

I'm not a fan of mutable result objects (aka in-process DTOs) - i.e. those with get; set; on every property) because without a primary-constructor there's no way to have hard assurances that an object instance will be initialized correctly.

(This is not to be confused with web-service DTOs, especially JSON DTOs, where there can be good reasons for making every property mutable, but that's another discussion)

If I were writing for an older .NET platform where MemberNotNullWhen isn't available, then I'd design DecryptResponse like below:

public abstract class DecryptResponse
{
    public static implicit operator DecryptResponse( Stream okStream )
    {
        return new DecryptResponse.OK( okStream );
    }

    public static implicit operator DecryptResponse( ( DecryptStatus status, String errorMessage ) pair )
    {
        return new DecryptResponse.Failed( pair.status, pair.errorMessage );
    }

    private DecryptResponse( DecryptStatus status )
    {
        this.Status = status;
    }

    public DecryptStatus Status { get; }

    public sealed class OK : DecryptResponse
    {
        public OK( Stream stream )
            : base( DecryptStatus.OK )
        {
            this.Stream = stream ?? throw new ArgumentNullException(nameof(stream));
        }

        public Stream Stream { get; }
    }

    public sealed class Failed : DecryptResponse
    {
        public Failed ( DecryptStatus status, String errorMessage )
            : base( status )
        {
            if( status == DecryptStatus.OK ) throw new ArgumentException( message: "Value cannot be " + nameof(DecryptStatus.OK) + "." );
            this.ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage));
        }

        public String ErrorMessage { get; }
    }
}

(From a CS-theoretical perspective, the above class is a union type).

The advantages of this design are numerous:

  • The class design makes it clear there's only 2 possible "shapes" of the result data: "OK" or "Failed" - and each subclass owns its context-specific data members (Stream and ErrorMessage respectively).
  • The type heirarchy is closed (the base abstract type has a private constructor) and its two child types are both sealed, making it impossible for there to be a result other than OK or Failed.
    • This is basically the same as an "enumerated (class) type", like Java's enum-classes. Whereas C# a enum is more like a named constant and the compiler and language provides no guarantees that a C# enum value is valid at runtime (e.g. you can always do MyEnum v = (MyEnum)123 even when 123 is not a defined value).
  • Validation logic in the OK and Failed constructors provide a guarantee that DecryptStatus.OK always means the result type is DecryptResponse.OK with a non-null Stream property. Similarly, if Status != DecryptStatus.OK you have a DecryptResponse.Failed object instead.
  • The implicit operator definitions mean that methods that return a DecryptResponse can directly return a Stream or ValueTuple<DecryptStatus,String> and the C# compiler will automagically perform the conversion for you.

Such a result-type is returned like so:

public DecryptResponse DecryptSomething()
{
    Stream someStream = ... // do stuff
    if( itWorked )
    {
        return someStream; // Returning a `Stream` invokes the DecryptResponse conversion operator method.
    }
    else
    {
        DecryptStatus errorStatus = ...
        return ( errorStatus, "someErrorMessage" ); // ditto for `ValueTuple<DecryptStatus,String>`
    }
}

or if you want to be explicit:

public DecryptResponse DecryptSomething()
{
    Stream someStream = ... // do stuff
    if( itWorked )
    {
        return new DecryptResponse.OK( someStream );
    }
    else
    {
        DecryptStatus errorStatus = ...
        return new DecryptResponse.Failed( errorStatus, "someErrorMessage" );
    }
}

And consumed like so:

DecryptResponse response = DecryptSomething();
if( response is DecryptResponse.OK ok )
{
    using( ok.Stream )
    {
        // do stuff
    }
}
else if( response is DecryptResponse.Failed fail )
{
    Console.WriteLine( fail.ErrorMessage );
}
else throw new InvalidOperationException("This will never happen.");

(Unfortunately the C# compiler is not yet smart enough to recognize closed-type hierarchies, hence the need for the else throw new... statement, but hopefully eventually that won't be necessary).

If you need support for serialization with JSON.net then you don't need to do anything, as JSON.NET supports serialization of these types just fine - but if you need to deserialize them then then you'll need a custom contract resolver, unfortunately - but writing a general-purpose contract-resolver for closed-types is straightforward and once you've written one you won't need to write another.

Azaleah answered 13/6, 2021 at 2:10 Comment(4)
Your StatusIsOK is private, how do you access it outside the class?Manzo
@Manzo By not using that version and using the DecryptResponse.OK class below it instead.Azaleah
Is it possible only depends on the Enum? Without additional bool check.Manzo
@Manzo No, because C#s nullable analysis (and NotNullWhen/MaybeNullWhen attribute types) only support flow-analysis on const bool values, not enum types: it's a limitation we have no control over. I agree it's an annoying limitation, though.Azaleah
P
1

NotNullWhenAttribute is for usage with parameters only. It tells the compiler that (out) parameter is not null when the method returned specified value (true or false). E.g.

public bool TryParse(string s, [NotNullWhen(true)] out Person person);

It means that person will not be null when method returned true.

But this attribute is not suitable for your what you trying to achieve:

  • NotNullWhen cannot be applied to class properties - it can be used only with method parameters.
  • NotNullWhen does not provide dependency on some external value (like class property) - it can only use the return value of the method parameter belongs to. And even more, this return value can be only boolean.

But you can try to use method instead

public bool TryDecrypt(Foo bar,
    [NotNullWhen(false) out DecryptError error, // wraps error status & message
    [NotNullWhen(true)] out Stream stream)

Or use null-forgiving operator

if (decryptResponse.Status == DecryptStatus.Ok)
{
    // decryptResponse.Stream!
}
Plop answered 28/5, 2020 at 13:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.