Fun with generics: building a generic call without reflection

The technique is demoed on my github.

DDD can lead to generic types that are hard to import

If you are strict in writing a domain model independently of any external concerns such as mapping data into the model or exporting from the model, and also like to use the type system as much as possible, you can end up with interesting technical challenges.

In my current project I was tasked to create a new model for some physics measurement domain.
Within that domain there were consistency constraints across different entities regarding their units.
I decided to try and enforce the consistency through the type system and thus have compile-time safety (the best kind of safety ;-)).
This meant that I ended up having types that were generic on physical units and then a wrapper type enforcing the consistency of their unit type arguments.
It looked a bit like this skeleton code:

public class MeasurementItem<TStimulusUnit, TMeasurementUnit>
    where TStimulusUnit : Unit
    where TMeasurementUnit : Unit
{
    public MeasurementItem(
        Stimulus<TStimulusUnit> stimulus,
        Evaluation<TStimulusUnit, TMeasurementUnit> evaluation,
        Classification<TMeasurementUnit> classification)
    {
    }
}

public class Stimulus<TUnit> where TUnit : Unit
{
}

public class Evaluation<TStimulusUnit, TMeasurementUnit>
    where TStimulusUnit : Unit
    where TMeasurementUnit : Unit
{
}

public class Classification<TUnit> where TUnit : Unit
{
}

public abstract class Unit
{
}

Note how the MeasurementItem constructor enforces the consistency between its three constituents in terms of units.
It is now impossible for a developer to compile code that violates the unit consistency.

As nice as it was to have such error-proof types, it left me with the technical puzzle of how to import into that model,
At some point in the import process we need to make the transition from some external data structure to a generic function or constructor call.
Let’s strip all the details of the concrete use-case away and concentrate on that very problem.
Disregard the physics problem above and imagine this is the function we need to implement to instantiate a Result.

static class Mapper
{
    public static Result GetResult(string oneStr, string twoStr)
    {
        // how to transition from strings to the generic call necessary to instantiate a Result?
    }
}

public abstract class Result
{
    Result()
    {
    }

    public sealed class OfType<T1, T2> : Result
    {
    }
}

If you are wondering why there is an abstract Result class: in the original problem I actually defined abstract non-generic classes for all the generic classes to enable the consumer of, say, a MeasurementItem to be able to access its properties without having to match or cast to a specific closed generic MeasurementItem<SomeUnit, SomeOtherUnit>. The abstract class thus served as a “view” that is independent of the particular generic type arguments while the nested generic class enforced the unit constraints at compile-time instantiation.

Anyway, the GetResult method gets some external data structure (in this case two strings) and needs to somehow make a call to new Result.OfType<,> with the generic type arguments filled in.
Let’s say the code needs to pass these unit tests:

Assert.IsAssignableFrom<Result.OfType<int, double>>(Mapper.GetResult("int", "double"));
Assert.IsAssignableFrom<Result.OfType<string, double>>(Mapper.GetResult("string", "double"));
Assert.IsAssignableFrom<Result.OfType<DateTime, Version>>(Mapper.GetResult("DateTime", "Version"));

One way of implementing this is to map the strings to the appropriate Type and then use those to build a generic constructor call using reflection.
This of course defeats the whole purpose of our constraints being compile-time enforced.

At this point we could decide that the technical difficulties that our domain model causes outweigh its benefits and refactor it to enforce the constraints at runtime instead, covered by a plethora of unit tests.
That can be sensible and I would not argue against such a decision.

But what if we stubbornly stuck with our precious compile-time safety?
How can we build up a generic call with two type arguments without using reflection?

For the sake of argument, suppose there were many types to which each of the two strings must be mapped and there are other mappings that require more type arguments (we will get to that later).
This rules out naively switching over each and every combination of two recognized strings.

Import can be done in a three-step process

What we need is a way to somehow “map” each string separately to some generic type argument, aggregate those type arguments and then call the Result.OfType<,> constructor with those aggregated type arguments.

Step 1: Save the generic type argument in a dummy type

The first step is to “save” the generic type argument in some dummy type for later use:

abstract class TypeArgument
{
    public static TypeArgument For<T>() => _<T>.Instance;

    TypeArgument() { }

    sealed class _<T> : TypeArgument
    {
        public static _<T> Instance = new _<T>();

        _() { }
    }
}

static class Mapper
{
    public static Result GetResult(string oneStr, string twoStr)
    {
        TypeArgument typeArg1 = ToTypeArgument(oneStr);
        TypeArgument typeArg2 = ToTypeArgument(twoStr);

        // TODO: aggregate the type arguments

        // TODO: call the generic constructor
    }

    public static TypeArgument ToTypeArgument(string str) =>
        str switch
        {
            "int" => TypeArgument.For<int>(),
            "double" => TypeArgument.For<double>(),
            "string" => TypeArgument.For<string>(),
            "DateTime" => TypeArgument.For<DateTime>(),
            "Version" => TypeArgument.For<Version>(),
            _ => throw new NotImplementedException(),
        };
}

Step 2: Aggregate the type arguments

To make the constructor call at some point we need some entity that is generic on both type arguments.
We would like to aggregate the type arguments we already have somewhat similar to this

var aggregatedTypeArgs = typeArg1.Aggregate(typeArg2);

Because the consuming code operates on abstract TypeArgument, this is where the Aggregate method must be defined.
It must be abstract, because only the nested generic class knows its type argument.
Its implementation must double-dispatch on the type arguments to create a dummy type wrapping both.

The dummy type looks like TypeArgument, but with two generic type parameters:

abstract class TypeArguments2
{
    public static TypeArguments2 For<T1, T2>() => _<T1, T2>.Instance;

    TypeArguments2() { }

    sealed class _<T1, T2> : TypeArguments2
    {
        public static _<T1, T2> Instance = new _<T1, T2>();

        _() { }
    }
}

We declare an abstract Aggregate method on TypeArgument to call for the consumer.
Additionally, for the double dispatch we declare another abstract method for internal usage only:

public abstract TypeArguments2 Aggregate(TypeArgument typeArgument);
private protected abstract TypeArguments2 AddToType<TOther>();

And then implement both in the generic nested class:

public override TypeArguments2 Aggregate(TypeArgument typeArgument) => typeArgument.AddToType<T>();
// this is where the double dispatch happens to collect both type arguments
private protected override TypeArguments2 AddToType<TOther>() => TypeArguments2.For<TOther, T>();

Funny side-note: the AddToType method could actually be private, but C# does not allow private abstract methods (too much of a niche case I assume).

Step 3: Call the generic method

All that is left is to make the constructor call with the type arguments wrapped in TypeArguments2.
To finish the first implementation we will hard-code everything to serve our Result use-case and enable this:

Result result = aggregatedTypeArgs.CreateResult();

We define an abstract method CreateResult for the consumer to call on their TypeArguments2 instance:

public abstract Result CreateResult();

We implement the method in the nested class:

public override Result CreateResult() => new Result.OfType<T1, T2>();

The whole picture

Putting everything together, our code now looks like this:

static class Mapper
{
    public static Result GetResult(string oneStr, string twoStr)
    {
        TypeArgument typeArg1 = ToTypeArgument(oneStr);
        TypeArgument typeArg2 = ToTypeArgument(twoStr);

        TypeArguments2 aggregatedTypeArgs = typeArg1.Aggregate(typeArg2);

        Result result = aggregatedTypeArgs.CreateResult();
        return result;
    }

    public static TypeArgument ToTypeArgument(string str) =>
        str switch
        {
            "int" => TypeArgument.For<int>(),
            "double" => TypeArgument.For<double>(),
            "string" => TypeArgument.For<string>(),
            "DateTime" => TypeArgument.For<DateTime>(),
            "Version" => TypeArgument.For<Version>(),
            _ => throw new NotImplementedException(),
        };
    }

abstract class TypeArgument
{
    public static TypeArgument For<T>() => _<T>.Instance;

    TypeArgument() { }

    public abstract TypeArguments2 Aggregate(TypeArgument typeArgument);

    private protected abstract TypeArguments2 AddToType<TOther>();

    sealed class _<T> : TypeArgument
    {
        public static _<T> Instance = new _<T>();

        _() { }

        public override TypeArguments2 Aggregate(TypeArgument typeArgument) => typeArgument.AddToType<T>();

        private protected override TypeArguments2 AddToType<TOther>() => TypeArguments2.For<TOther, T>();
    }
}

abstract class TypeArguments2
{
    public static TypeArguments2 For<T1, T2>() => _<T1, T2>.Instance;

    TypeArguments2() { }

    public abstract Result CreateResult();

    sealed class _<T1, T2> : TypeArguments2
    {
        public static _<T1, T2> Instance = new _<T1, T2>();

        _() { }

        public override Result CreateResult() => new Result.OfType<T1, T2>();
    }
}


The Mapper code is easy to follow.
The TypeArguments are a bit strange to look at at first.
They are basically boilerplate for the compiler.

In the final chapter, we will look at how we could generalize this in terms of arity and final call.

Generalizations

Number of type parameters

Supporting more type arguments is reasonably easy.
We follow the pattern of TypeArguments2 for the type argument wrapper TypeArguments3.
We add the generic call we need to TypeArguments3.
We add an AddToTypes<TOther1, TOther2>() method to TypeArgument.
Then we add an Aggregate method to TypeArguments2 and implement it using the new AddToTypes.

An example of such generalization (with different class names) can be found on my github.

Type constraints

Another enhancement of this approach is the addition of type constraints.
Imagine that the Results constructor looked like this:

public OfType<T1, T2>() where T1: class, T2: new() { }

To satisfy those constraints during our compile-time-safe generic call building, we would have to add the type constraints to the TypeArgumentsN classes as well.

Generic method call

Unfortunately, generalizing over the final call is IMO impossible.
The best I could come up with is to have a Call method on the TypeArgumentsN class with one parameter that defines the call to make with its N type arguments.
Since we do not have open generic delegates in C#, we have to use an interface containing a single method that is generic with two type parameters, i. e. defined nested in the TypeArguments2 class:

public abstract TResult Call<TResult>(ICallTarget<TResult> callTarget);

public interface ICallTarget<TResult>
{
    TResult Invoke<T1, T2>();
}

We can then have this implemented by the consumer:

sealed class ResultTarget : TypeArguments2.ICallTarget<Result>
{
    public Result Invoke<T1, T2>() => new Result.OfType<T1, T2>();
}

This is not much of a generalization though.
There is no way to generalize over the call target’s method parameters (and their types).

This means that the technique introduced here cannot be fully supplied as a library.
It remains a pattern that can be used once we know what our call target looks like.

Being a pattern, a source generator could be supplied, however, that at least takes the burden of writing the boilerplate off the developer and only leaves the call target for them to define.