Simulating Return Type Inference in C#


I really like more than anything in software development to make frameworks that allow other developers to create something cool. Sometimes, in the pursuit of perfect code, strange ideas come to my mind, the implementation of which C # can reach its limit.

Not so long ago, a similar case occurred when we, together with a colleague, were looking for a way to avoid passing a large number of type parameters in places where the compiler should have inferred them. However, C# is designed in such a way that it is only able to infer types in generic calls from the passed method parameters.

In this article, I’ll show you a little trick that will allow you to simulate type inference for return values, and a few examples where this can be useful.


Type inference

Type inference is the compiler’s ability to automatically infer the type of an expression without explicitly specifying it. This feature works by analyzing the context in which the expression is evaluated, given the constraints imposed by the surrounding data flow in the program.

The ability to automatically recognize the type allows programmers in this language to write concise code, taking full advantage of static typing. Therefore, most popular languages ​​have some form of type inference.

C# is one of them. The simplest example that demonstrates this is the keyword var:

var x = 5;              // int
var y = "foo";          // string
var z = 2 + 1.0;        // double
var g = Guid.NewGuid(); // Guid

When using the var keyword within an assignment-combined declaration, it is not required to specify the type of the variable. The compiler is able to figure it out on its own based on the expression on the right.

Along the same lines, C# allows you to initialize an array without the need for an explicit type:

var array = new[] {"Hello", "world"}; // string[]

Here the compiler sees that an array with two string elements is being initialized, from which it can safely infer that the resulting variable type is string[]. In some special cases, it can even infer the type based on the most common type among the elements:

var array = new[] {1, 2, 3.0}; // double[]

Still, the most interesting aspect of type inference in C# is, of course, the generic methods. When calling such a method, type arguments can be ignored, since they can be inferred from the values ​​passed to the method parameters.

For example, you can define a generic method List.Create<T>which creates a list from a sequence of elements:

public static class List
{
    public static List<T> Create<T>(params T[] items) => new List<T>(items);
}

Which can be used like this:

var list = List.Create(1, 3, 5); // List<int>

In the example above, one could write explicitly List.Create<int>(...), but this was not necessary. Based on the parameters passed to the method, the compiler independently determined the type, on which the return value also depends.

Interestingly enough, all of the examples above are based on the same form of type inference, which works by parsing the constraints imposed by other expressions whose type is already known. In other words, research incoming data flow and draw conclusions about outgoing.

However, there are also scenarios where we want to have type inference backwards. Let’s see where it can be useful.

Option type

If you’ve ever written code in a functional style before, chances are you’re very familiar with the type. Option<T>. It is a container that holds a value along with the fact that it exists, and allows operations to be performed on it without the need to observe state.

In C#, such a type is usually defined by two fields – a value of some type and a flag indicating the presence of this value. This can be represented as follows:

public readonly struct Option<T>
{
    private readonly T _value;
    private readonly bool _hasValue;

    private Option(T value, bool hasValue)
    {
        _value = value;
        _hasValue = hasValue;
    }

    public Option(T value)
        : this(value, true)
    {
    }

    public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none) =>
        _hasValue ? some(_value) : none();

    public void Match(Action<T> some, Action none)
    {
        if (_hasValue)
            some(_value);
        else
            none();
    }

    public Option<TOut> Select<TOut>(Func<T, TOut> map) =>
        _hasValue ? new Option<TOut>(map(_value)) : new Option<TOut>();

    public Option<TOut> Bind<TOut>(Func<T, Option<TOut>> bind) =>
        _hasValue ? bind(_value) : new Option<TOut>();
}

This API is quite simple. The implementation above hides the value from its consumers, leaving only the method exposed on the surface. Match(...), which handles both possible container states. There are additional methods Select(...) and Bind(...)which are used to safely transform a value, whether it exists or not.

Also, in this example, Option<T> declared as readonly struct. Given that in the future, objects of this type will either be returned from methods or used in local scopes, the decision on such a declaration was made for performance reasons.

To make the use of the type more convenient, you can provide factory methods that help you create instances more flexibly Option<T>:

public static class Option
{
    public static Option<T> Some<T>(T value) => new Option<T>(value);

    public static Option<T> None<T>() => new Option<T>();
}

Usage example:

public static Option<int> Parse(string number)
{
    return int.TryParse(number, out var value)
        ? Option.Some(value)
        : Option.None<int>();
}

It can be seen that in the case of a call Option.Some<T>(...) you can omit the type parameter because the compiler can infer it based on the type valuewhich is int. On the other hand, the same approach does not work with the method Option.None<T>(...)because it doesn’t have any parameters. As a result, you need to specify the type manually.

Although the default setting for Option.Some<T>(...), seems obvious from the context, the compiler is unable to infer it. Because, as mentioned earlier, type inference in C# works only by parsing the incoming data stream, and not vice versa.

Of course, ideally, I would like the compiler to figure out the type itself T in Option.None<T>(..)based on returned the type of expression it must have according to the method signature. Otherwise, I would like to get the type Tas a branch of the ternary operator, based on the type value.

Unfortunately, neither scenario is possible in C#, because then the type system would have to deal with the parsing of the outgoing data stream, which it does not. However, you can help her.

Can be simulated return type inputforcing Option.None return a special value of a non-generic type that could be cast to Option<T>. This is how it might look like:

public readonly struct Option<T>
{
    private readonly T _value;
    private readonly bool _hasValue;

    private Option(T value, bool hasValue)
    {
        _value = value;
        _hasValue = hasValue;
    }

    public Option(T value)
        : this(value, true)
    {
    }

    // ...

    public static implicit operator Option<T>(NoneOption none) => new Option<T>();
}

public readonly struct NoneOption
{
}

public static class Option
{
    public static Option<T> Some<T>(T value) => new Option<T>(value);

    public static NoneOption None { get; } = new NoneOption();
}

As you can see, Option.None returns an empty type NoneOption, which models an empty container, respectively, and no matter what type. Type of NoneOption not generic, so you can omit the type parameters and turn Option.None to property.

Also in Option<T> now there is an implicit conversion from NoneOption. Although operators cannot be generic in C#, they can still use type parameters declared in the type containing the operator. This allows you to define transformations to all possible variations Option<T>.

All this makes it possible to use Option.None the way it was originally planned. From the developer’s point of view, it looks like a return type inference appeared in the language:

public static Option<int> Parse(string number)
{
    return int.TryParse(number, out var value)
        ? Option.Some(value)
        : Option.None;
}

Result type

The same schemes applied to Option can be pulled onto the type Result<TOk, TError>. This type serves the same purpose, except that it provides an integer value to handle negative scenarios.

This way it could be implemented:

public readonly struct Result<TOk, TError>
{
    private readonly TOk _ok;
    private readonly TError _error;
    private readonly bool _isError;

    private Result(TOk ok, TError error, bool isError)
    {
        _ok = ok;
        _error = error;
        _isError = isError;
    }

    public Result(TOk ok)
        : this(ok, default, false)
    {
    }

    public Result(TError error)
        : this(default, error, true)
    {
    }

    // ...
}

public static class Result
{
    public static Result<TOk, TError> Ok<TOk, TError>(TOk ok) =>
        new Result<TOk, TError>(ok);

    public static Result<TOk, TError> Error<TOk, TError>(TError error) =>
        new Result<TOk, TError>(error);
}

And here’s how to use it:

public static Result<int, string> Parse(string input)
{
    return int.TryParse(input, out var value)
        ? Result.Ok<int, string>(value)
        : Result.Error<int, string>("Invalid value");
}

Here the situation with type inference is absolutely horrific. Neither Result.Ok<TOk, TError>(...)nor Result.Error<TOk, TError>(...) don’t have enough arguments to print type parameters. Therefore, we are forced to explicitly specify them in both cases.

Having to explicitly write these types brings visual noise, code duplication, and generally bad development practices. Let’s try to fix this using the same techniques as before:

public readonly struct Result<TOk, TError>
{
    private readonly TOk _ok;
    private readonly TError _error;
    private readonly bool _isError;

    private Result(TOk ok, TError error, bool isError)
    {
        _ok = ok;
        _error = error;
        _isError = isError;
    }

    public Result(TOk ok)
        : this(ok, default, false)
    {
    }

    public Result(TError error)
        : this(default, error, true)
    {
    }

    public static implicit operator Result<TOk, TError>(DelayedResult<TOk> ok) =>
        new Result<TOk, TError>(ok.Value);

    public static implicit operator Result<TOk, TError>(DelayedResult<TError> error) =>
        new Result<TOk, TError>(error.Value);
}

public readonly struct DelayedResult<T>
{
    public T Value { get; }

    public DelayedResult(T value)
    {
        Value = value;
    }
}

public static class Result
{
    public static DelayedResult<TOk> Ok<TOk>(TOk ok) =>
        new DelayedResult<TOk>(ok);

    public static DelayedResult<TError> Error<TError>(TError error) =>
        new DelayedResult<TError>(error);
}

Similarly, we defined the type DelayedResult<T>modeling the initialization Result<TOk, TError>. Again, implicit casting is used to move from lazy initialization to the desired container.

All done allows you to rewrite the code as follows:

public static Result<int, string> Parse(string input)
{
    return int.TryParse(input, out var value)
        ? (Result<int, string>) Result.Ok(value)
        : Result.Error("Invalid value");
}

Slightly better, but not perfect. The problem is that the ternary operator in C# does not bring branches to a “common denominator”. Because of this, you have to explicitly cast to Result<int, string> branch of truth.

However, this behavior can be avoided by using the classic conditional expression:

public static Result<int, string> Parse(string input)
{
    if (int.TryParse(input, out var value))
        return Result.Ok(value);

    return Result.Error("Invalid value");
}

This configuration is more satisfactory. It is possible to completely omit the type parameters without changing the signatures and using type safety. Again, the illusion is created that these parameters are inferred based on the expected return type.

That being said, you might have noticed a bug in the current implementation. If a TOk and TError are the same, then there will be ambiguity: which option DelayedResult<T> use.

Imagine, as an example, such a scenario using our type:

public interface ITranslationService
{
    Task<bool> IsLanguageSupportedAsync(string language);

    Task<string> TranslateAsync(string text, string targetLanguage);
}

public class Translator
{
    private readonly ITranslationService _translationService;

    public Translator(ITranslationService translationService)
    {
        _translationService = translationService;
    }

    public async Task<Result<string, string>> TranslateAsync(string text, string language)
    {
        if (!await _translationService.IsLanguageSupportedAsync(language))
            return Result.Error($"Language {language} is not supported");

        var translated = await _translationService.TranslateAsync(text, language);
        return Result.Ok(translated);
    }
}

Here Result.Error<TError>(...) and Result.Ok<TOk>(...) both return DelayedResult. So the compiler has a hard time figuring out what to do with it:

Cannot convert expression type 'DelayedResult<string>' to return type 'Result<string,string>'

Fortunately, fixing this is simple – you just need to represent each state as a separate type:

public readonly struct Result<TOk, TError>
{
    private readonly TOk _ok;
    private readonly TError _error;
    private readonly bool _isError;

    private Result(TOk ok, TError error, bool isError)
    {
        _ok = ok;
        _error = error;
        _isError = isError;
    }

    public Result(TOk ok)
        : this(ok, default, false)
    {
    }

    public Result(TError error)
        : this(default, error, true)
    {
    }

    public static implicit operator Result<TOk, TError>(DelayedOk<TOk> ok) =>
        new Result<TOk, TError>(ok.Value);

    public static implicit operator Result<TOk, TError>(DelayedError<TError> error) =>
        new Result<TOk, TError>(error.Value);
}

public readonly struct DelayedOk<T>
{
    public T Value { get; }

    public DelayedOk(T value)
    {
        Value = value;
    }
}

public readonly struct DelayedError<T>
{
    public T Value { get; }

    public DelayedError(T value)
    {
        Value = value;
    }
}

public static class Result
{
    public static DelayedOk<TOk> Ok<TOk>(TOk ok) =>
        new DelayedOk<TOk>(ok);

    public static DelayedError<TError> Error<TError>(TError error) =>
        new DelayedError<TError>(error);
}

Returning to the code written earlier, we will see that it works as required:

public class Translator
{
    private readonly ITranslationService _translationService;

    public Translator(ITranslationService translationService)
    {
        _translationService = translationService;
    }

    public async Task<Result<string, string>> TranslateAsync(string text, string language)
    {
        if (!await _translationService.IsLanguageSupportedAsync(language))
            return Result.Error($"Language {language} is not supported");

        var translated = await _translationService.TranslateAsync(text, language);
        return Result.Ok(translated);
    }
}

Conclusion

While there are limitations to C#’s type inference, the language can be forced to push them back a little with implicit type conversion. Using the simple trick shown in the article, you can simulate a return type inference, opening up potentially interesting architectural possibilities along the way.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *