Search code examples
c#functional-programminglanguage-ext

LanguageExt - Compiler error using method that returns Task<Either<string, T>> in a Linq query


Having thought I'd got the hang of this, I'm stuck again!

I'm trying to write a method that takes an encrypted string as a parameter. That string contains the serial number and password of a device that is to be registered in our database, along with a corresponding ASP.NET Identity Core user. That isn't strictly relevant to the question though, but might explain the method names.

The first two code snippets that follow form an MRE, and can be pasted directly into LinqPad. You'll need to reference LanguageExt, add a using for it, as well as a static using for LanguageExt.Prelude.

I have the following (simplified) methods...

static Either<Unit, (string serialNumber, string password)> Decrypt(string data) =>
  // Decrypt the input string and extract the serial number and password
  ("serial", "password");

static async Task<Either<Unit, Unit>> CheckIfDeviceExists(string serialNumber) =>
  // In reality this would check the database, simplified for this sample
  serialNumber.Contains("x")
    ? Right(unit)
    : Left(unit);

static async Task<Either<Unit, Unit>> CheckIfUserExists(string serialNumber) =>
  // In reality this would check the database, simplified for this sample
  serialNumber.Contains("x")
    ? Right(unit)
    : Left(unit);

static async Task<Either<string, User>> CreateUser(string serialNumber, string password) =>
  // Create an ASP.NET Core Identity user and return it, or a string containing any errors
  new User();

static async Task<Either<string, Unit>> CreateDevice(User user, string serialNumber) =>
  // This would create a new device and save it to the database. Left is any error
  unit;

// Simulate model
record User();

I want to glue these together as follows...

static async Task RegisterDevice(string data) {
  // In the real code, we match on the expression below and return an ASP.NET
  // Core IResult based on it being Left or Right. For simplicity, this sample
  // method doesn't return anything, and doesn't call Match
  var jim = await (
    from d in Decrypt(data).ToAsync()
    from _1 in CheckIfDeviceExists(d.serialNumber).ToAsync()
    from _2 in CheckIfUserExists(d.serialNumber).ToAsync()
    from user in CreateUser(d.serialNumber, d.password).ToAsync()
    from _3 in CreateDevice(user, d.serialNumber).ToAsync()
    select unit
  );
}

The problem is on the lines that call CreateUser and CreateDevice. On the former line, I get a compiler error of "CS0029 Cannot implicitly convert type 'LanguageExt.EitherAsync<string, User>' to 'LanguageExt.Guard<LanguageExt.Unit>'. CS8016 Transparent identifier member access failed for field '<>h__TransparentIdentifier1' of 'int'. Does the data being queried implement the query pattern?" The second line gives something very similar.

Based on the answer Mark Seemann gave me in an earlier question, I thought I could fix this by changing the line to...

from user in RightAsync<string, User>(CreateUser(d.serialNumber, d.password))

...but this highlighted the call to CreateUser with the compiler error "CS1503 Argument 1: cannot convert from 'System.Threading.Tasks.Task<LanguageExt.Either<string, User>>' to 'System.Threading.Tasks.Task'. CS8016 Transparent identifier member access failed for field '<>h__TransparentIdentifier1' of 'int'. Does the data being queried implement the query pattern?"

I'm guessing that the difference here is that the methods return Task<Either<>>, whereas the one in the previous question just returned a Task<T>. Not sure of that's right though.

As Mark suggested at the beginning of his answer there, my problem was that the types weren't in the same monad. I tried adding the following line into the query to check the types...

let x = CreateUser(d.serialNumber, d.password).ToAsync()

...and this showed that x is of type EitherAsync<string, User>, which is the same monad as the previous values (albeit varying over generic types), so I'm not sure that this is the same problem.

Anyone able to explain what I'm doing wrong? Thanks


Solution

  • As already outlined, when you use C# query syntax, all variables have to belong to the same monad type. The first variable determines the type. Here, it's

    from d in Decrypt(data).ToAsync()
    

    Decrypt returns Either<Unit, (string serialNumber, string password)>, and ToAsync turns it into an EitherAsync<Unit, (string serialNumber, string password)>.

    Thus, the monad in question is EitherAsync<Unit, T> and all other from-bound values in the query expression must have that type. This works fine for _1 (EitherAsync<Unit, Unit>) and _2 (EitherAsync<Unit, Unit>).

    When you reach CreateUser it no longer works because the return type (after ToAsync) is EitherAsync<string, User>. The left types don't match.

    If that's what you want to do, you can throw away the left strings with MapLeft:

    static async Task RegisterDevice(string data)
    {
        // In the real code, we match on the expression below and return an ASP.NET
        // Core IResult based on it being Left or Right. For simplicity, this sample
        // method doesn't return anything, and doesn't call Match
        var jim = await (
            from d in Decrypt(data).ToAsync()
            from _1 in CheckIfDeviceExists(d.serialNumber).ToAsync()
            from _2 in CheckIfUserExists(d.serialNumber).ToAsync()
            from user in CreateUser(d.serialNumber, d.password).ToAsync().MapLeft(_ => unit)
            from _3 in CreateDevice(user, d.serialNumber).ToAsync().MapLeft(_ => unit)
            select unit
        );
    }
    

    Throwing away data is rarely, however, a good idea. Usually it'd be more appropriate to move the other way and return some left string instead of Unit for the other methods.

    You could also use MapLeft for that, but why even return Either<Unit, T> in the first place?

    There's no information in Unit, and Either<Unit, T> is isomorphic to Option<T>. Why not return Option from Decrypt, CheckIfDeviceExists, and CheckIfUserExists?

    Then in your query expression, you could 'promote' those Option values to Either values by providing an error string for the left cases.