Search code examples
c#genericsdependency-injectioncastingparametric-polymorphism

resolving actual runtime type of generic parameter rather than inferred type


First, setup

interface IRequirement { }
interface ITarget { ICollection<IRequirement> Requirements { get; set; } }
interface IRequirementHandler<TRequirement, TTarget>
    where TRequirement : IRequirement
    where TTarget : ITarget {}

class AgeMustBeGreaterThanThirtyRequirement : IRequirement {}
class Person : ITarget { int Age { get; set; } }
class PersonAgeMustBeGreaterThanThirtyRequirementHandler : 
    IRequirementHandler<AgeMustBeGreaterThanThirtyRequirement, Person> {}
// register requirement handler in DI
    services.AddSingleton
      <IRequirementHandler<AgeMustBeGreaterThanThirtyRequirement, Person>,
        PersonAgeMustBeGreaterThanThirtyRequirementHandler>();

Basically I have target entities, requirements, and requirements handlers. a requirement can be applied to multiple target entities. a requirement handler handles a concrete requirement against a target.

All ITarget entities have a collection of IRequirement

interface ITarget { ICollection<IRequirement> Requirements { get; set; } }

I have a manager class that serves as service locator using .NET IServiceProvider, to resolve concrete handler for each requirement.

interface IRequirementHandlerLocator 
{ 
    IRequirementHandler<TRequirement, TTarget> GetHandler<TRequirement, TTarget>()
        where TRequirement : IRequirement
        where TTarget : ITarget
}

this is where I realized how ignorant I am in the topic of generics and types in general. I could not implement IRequirementHandlerLocator as it is, so I had to change it to

interface IRequirementHandlerLocator 
{ 
    IRequirementHandler<TRequirement, TTarget> GetHandler<TRequirement, TTarget>(TRequirement requirement, 
        TTarget target) where TRequirement : IRequirement
                        where TTarget : ITarget
}

and implemented it this way

class RequirementHandlerLocator : IRequirementHandlerLocator
{
    IRequirementHandler<TRequirement, TTarget> GetHandler<TRequirement, TTarget>(TRequirement requirement, 
        TTarget target) where TRequirement : IRequirement
                        where TTarget : ITarget
    {
        return _serviceProvider.GetRequiredService<IRequirementHandler<TRequirement, TTarget>>();
    }
}

I thought to myself, this way generic types are infered when I call GetHandler(). for example:

ITaskRequirementHandlerLocator _requirementHandlerLocator;
bool DoesTargetSatisfyRequirements(ITarget target)
{
    foreach (var requirement in target.Requirements)
    {
        var handler = _requirementHandlerLocator.GetHandler(requirement, target);
        if (!handler.QualifyAgainst(target))
          return false;
    }
}

Those who have good understanding of the topic know that this failed. because although TRequirement actual runtime type was AgeMustBeGreaterThanThirtyRequirement it was resolved as IRequirement, the full resolved type => IRequirementHandler<IRequirement, ITarget>. and registration in DI was

// register requirement handler in DI
    services.AddSingleton
      <IRequirementHandler<AgeMustBeGreaterThanThirtyRequirement, Person>,
        PersonAgeMustBeGreaterThanThirtyRequirementHandler>();

so DI container did not find the type I learnt (and I am assuming) that generics captures/infers the given type rather than the actual runtime type. and since that I dont know the actual type in compile time =>

I need your help to solve this riddle, and perhaps add some information for me and those like me to deeper understand the issue.

EDIT: I asked microsoft's bing chatGPT like service about this. and it suggested

var handlerType = typeof(ITaskRequirementHandler<,>)
    .MakeGenericType(typeof(TRequirement), typeof(TTarget));

//success. DI resolved the handler.
var handler = _serviceProvider.GetRequiredService(handlerType);

//failure. casting failed.
return (IRequirementHandler<TRequirement, TTarget>) handler;

although the suggestion made resolved the correct handler type and DI returned the handler. but I could not cast it to the method signature.


Solution

  • As you're always passing IRequirement and ITarget at compile time as generic type arguments, the generic type arguments of IRequirementHandlerLocator become redundant. Therefore, I propose changing that abstraction to the following:

    interface IRequirementHandlerLocator 
    { 
        IRequirementHandler<IRequirement, ITarget> GetHandler(
            IRequirement requirement, ITarget target);
    }
    

    In this case, your RequirementHandlerLocator should become:

    class RequirementHandlerLocator : IRequirementHandlerLocator
    {
        IRequirementHandler<IRequirement, ITarget> GetHandler(
            IRequirement requirement, ITarget target)
        {
            var handlerType = typeof(IRequirementHandler<,>)
                .MakeGenericType(requirement.GetType(), target.GetType());
    
            object handler = _serviceProvider.GetRequiredService(handlerType);
    
            return (IRequirementHandler<IRequirement, ITarget>)handler;
        }
    }
    

    There's only one problem here and that's that the last cast in RequirementHandlerLocator will cause a runtime exception, stating that:

    Unable to cast object of type 'PersonAgeMustBeGreaterThanThirtyRequirementHandler' to type 'IRequirementHandler`2[IRequirement,ITarget]'.'

    That's because the interface is not variant and an IRequirementHandler<IRequirement, ITarget> is not the same as an IRequirementHandler<AgeMustBeGreaterThanThirtyRequirement, Person>. For them to become interchangable, you have to make IRequirementHandler<R, T> covariant. In other words, you have to add the out keyword to the generic type constraints. In other words:

    interface IRequirementHandler<out TRequirement, out TTarget>
        where TRequirement : IRequirement
        where TTarget : ITarget
    {
    }
    

    Such change, however, will only work when both generic type arguments are only used as output arguments. Your question doesn't mention this, but this will probably not be the case. This means that you will not get your code to work, whatever you try. Why this is the case is too work to explain here, but Eric Lippert has great introduction blog posts on generic typing, variance, covariance, and contravariance.

    A way around this is to let IRequirementHandler<R, T> inherit from a non-generic base type, e.g.:

    interface IRequirementHandler { }
    
    interface IRequirementHandler<out TRequirement, out TTarget>
        where TRequirement : IRequirement
        where TTarget : ITarget
        : IRequirementHandler
    {
    }
    

    This way IRequirementHandlerLocator can return IRequirementHandler instead. There are possibly other solutions but what the best solution is in your case is hard to tell, because you provided too little context for this.