Fixing C# reflection which was broken by upgrade to .NET 6

Why code which had been working for years started throwing runtime exceptions after upgrading to target .NET 6

The code that was broken by .NET 6 upgrade

This code was in a .NET Core 3.1 app and was working fine:

public void GetTranslation(Type translationType)
{
    // lots of code skipped

    var firstOrDefaultMethod = typeof(Enumerable).GetMethods().First(m =>
        m.Name == nameof(Enumerable.FirstOrDefault) && 
        m.GetParameters().Length == 2);
    var typedFirstOrDefaultMethod = firstOrDefaultMethod.MakeGenericMethod(translationType);
    var firstOrDefaultExpression = Expression.Call(
        typedFirstOrDefaultMethod,
        translationsObjectExpression, // created earlier in method
        translationFilterLambda); // created earlier in method

    // more code skipped
}

I updated the app to .NET 6 and it compiled fine, but at runtime an exception was thrown by Expression.Call.

System.ArgumentException: Expression of type 'System.Func`2[MyProject.Translation,System.Boolean]' cannot be used for parameter of type 'MyProject.Translation' of method 'MyProject.Translation FirstOrDefault[Translation](System.Collections.Generic.IEnumerable`1[MyProject.Translation], MyProject.Translation)' (Parameter 'arg1')

This error baffled me for a while. How could the code have broken? It was already working in production, and I hadn't changed any of the code. I eventually tracked the root cause down to this breaking change in .NET 6.

Why this code doesn't work in .NET 6

The key problem is in this line:

var firstOrDefaultMethod = typeof(Enumerable).GetMethods().First(m =>
    m.Name == nameof(Enumerable.FirstOrDefault) && 
    m.GetParameters().Length == 2);

This code makes an assumption about the overloads for Enumerable.FirstOrDefault, namely that there would only be a single overload with two parameters. That assumption was correct up to .NET 5, but .NET 6 introduced some new overloads. You can see this by comparing the .NET 5 overloads with the .NET 6 overloads. Significantly, there are now 2 overloads which have 2 parameters.

Solution

The breaking change documentation tells us the solution to this problem:

ensure that your reflection code is tolerant of method overload additions. For example, use a Type.GetMethod overload that explicitly accepts the method's parameter types.

Source: docs.microsoft.com/en-us/dotnet/core/compat..

Remember that the fundamental problem was that the original code assumed there would only be a single overload with two parameters. The solution was therefore to more precisely get the correct FirstOrDefault overload.

This is the updated code to get firstOrDefaultMethod, which works in .NET 6 (as well as earlier versions):

var genericParameter = Type.MakeGenericMethodParameter(0);
var enumerableType = typeof(IEnumerable<>).MakeGenericType(genericParameter);
var predicateType = typeof(Func<,>).MakeGenericType(genericParameter, typeof(bool));
var firstOrDefaultMethod = typeof(Enumerable).GetMethod(
    nameof(Enumerable.FirstOrDefault),
    new[] { enumerableType, predicateType });

Note the change to using GetMethod rather than GetMethods. This allows us to specify an array of specific types which the method takes as parameters. Knowing the exact parameter types allows the code to uniquely identify the correct overload.

Further reading

I have written about the role of unit testing in this bug fix, you can read about that in the following article: