Converting between types in increasingly absurd ways

Markus Mayer
16 min readJan 9, 2022

--

Today I was answering a C# question on StackOverflow that got me thinking. The request was slightly odd, but reasonable: Given a SomeType and a SomeTypeDTO class as well as a corresponding extension method

public static SomeTypeDTO ToDTO(this SomeType data) { ... }

how can one write a generic method

public static TDTO ToDTO<TDTO, TData>(TData data) { ... }

that forwards to the right extension method(s). Some ten minutes into thinking about how to explain why it’s complicated and why it might not even be a good idea to begin with but how AutoMapper might solve the issue … the OP deleted the question.

But sometimes, the only reason to really do something is because you can, and so I set off to explore the solution space. In this post, I will go through implementing some approaches using

  1. Simple Generics (TL;DR: won’t work)
  2. Reflection with MethodInfo invocations,
  3. Reflection with runtime compilation of Lambda expressions,
  4. Compile-time Source Generation and
  5. Using AutoMapper, for the sake of sanity.

You can find the source code for this blog post on GitHub:

To have a starting point, these are the data classes and DTOs the author used:

namespace ExtensionMethods70642141;

public class Student
{
public string Name { get; set; }
}

public class StudentDTO
{
public string Name { get; set; }
}

public class Teacher
{
public string Name { get; set; }
}

public class TeacherDTO
{
public string Name { get; set; }
}

To convert, the author provided these extension methods:

namespace ExtensionMethods70642141;

public static class PeopleExtension
{
public static StudentDTO ToDTO(this Student student) => new()
{
Name = student.Name
};

public static TeacherDTO ToDTO(this Teacher teacher) => new()
{
Name = teacher.Name
};
}

And to recap, the question is:

namespace ExtensionMethods70642141;

public static class GenericPeopleConversion
{
public static TDTO ToDTO<TDTO, TData>(TData data)
{
throw new System.NotImplementedException("how?");
}
}

The code used for testing would look like this:

using Xunit;

namespace ExtensionMethods70642141;

public class SmokeTests
{
[Fact]
public void Smoke()
{
var student = new Student { Name = "Student Name" };
var teacher = new Teacher { Name = "Teacher Name" };

var studentDto = student.ToDTO();
var teacherDto = teacher.ToDTO();

Assert.Equal(student.Name, studentDto.Name);
Assert.Equal(teacher.Name, teacherDto.Name);
}
}

Approach #1: Generics won’t help us much

At a first glance, one might be tempted to convert the arguments to constrained generics first, such that

namespace ExtensionMethods70642141;

public static class PeopleExtension
{
public static StudentDTO ToDTO<TData>(this TData student)
where TData : Student
=> new()
{
Name = student.Name
};

public static TeacherDTO ToDTO<TData>(this TData teacher)
where TData : Teacher
=> new()
{
Name = teacher.Name
};
}

But the moment you do that, you’ll be greeted with compiler error CS0111 informing you that there already is a method with the same name and argument:

Nope.cs(12, 30): [CS0111] Type 'PeopleExtension' already defines a member called 'ToDTO' with the same parameter types

One way to resolve this issue is to distribute the extension methods across different classes, e.g.

public static class PeopleExtension1
{
public static StudentDTO ToDTO<TData>(this TData student)
where TData : Student
=> new()
{
Name = student.Name
};
}

public static class PeopleExtension2
{
public static TeacherDTO ToDTO<TData>(this TData teacher)
where TData : Teacher
=> new()
{
Name = teacher.Name
};
}

Older compilers would fail even with this, but at some point in the recent past (as of 2022), a tie breaker was implemented to assist. Now in order to get the TDTO output type, we have to introduce both a new() constraint, as well as one on the proper DTO type as we would be unable otherwise to create an instance, nor assign the property:

public static class PeopleExtension1
{
public static TDTO ToDTO<TDTO, TData>(this TData student)
where TData : Student
where TDTO : StudentDTO, new()
=> new()
{
Name = student.Name
};
}

public static class PeopleExtension2
{
public static TDTO ToDTO<TDTO, TData>(this TData teacher)
where TData : Teacher
where TDTO : TeacherDTO, new()
=> new()
{
Name = teacher.Name
};
}

Now calling the extension methods still works:

[Fact]
public void Smoke()
{
var student = new Student { Name = "Student Name" };
var teacher = new Teacher { Name = "Teacher Name" };

var studentDto = student.ToDTO<StudentDTO, Student>();
var teacherDto = teacher.ToDTO<TeacherDTO, Teacher>();

Assert.Equal(student.Name, studentDto.Name);
Assert.Equal(teacher.Name, teacherDto.Name);
}

No sadly, we didn’t really gain anything: We still have to know the exact type in order to pick the right extension method. The moment we erase the actual types …

public TDTO Convert<TDTO, TData>(TData data) =>
data.ToDTO<TDTO, TData>();

… the compiler calls us a clown using error CS0314:

SmokeTests.cs(21, 14): [CS0314] The type 'TData' cannot be used as type parameter 'TData' in the generic type or method 'PeopleExtension1.ToDTO2<TDTO, TData>(TData)'. There is no boxing conversion or type parameter conversion from 'TData' to 'ExtensionMethods70642141.Student'.

And since we just split the extension methods into multiple classes, we also cannot call the method directly anymore.

Scratch that.

Approach #2: Reflection with MethodInfo invocations

The next best thing to typing out an if cascade of every combination of accepted input and output type is to look types up at runtime. Since reflection is costly, we can cache our lookup results in a static dictionary, and now the only question is whether we want to use look up each specific method pessimistically once it is required, or to optimistically look up all ToDTO methods once, and only once. Below I will go with the first approach as it is slightly less convoluted.

The main part here will be the introspection of the PeopleExtension class for all its public static methods, finding every candidate named ToDTO that has an output type matching TDTOand exactly one argument matching the TData type:

var methodInfo = typeof(PeopleExtension)
.GetMethods(BindingFlags.Static | BindingFlags.Public)
.Where(method =>
method.Name.Equals(nameof(PeopleExtension.ToDTO)))
.Where(method => outputType == method.ReturnType)
.FirstOrDefault(method =>
inputType == method.GetParameters()
.SingleOrDefault()?
.ParameterType);

This provides us with the MethodInfo of the relevant method, if one exists (methodInfo would be null otherwise).

Since we are only looking at one specific class (namely PeopleExtension), there can be at most one Y ToDTO(X data) method per type X as otherwise the methods would differ only in their return type, which is forbidden. We can therefore introduce a static cache dictionary keyed by the concrete type X that stores the relevant MethodInfo, allowing us to skip the reflection call next time around:

private static readonly ConcurrentDictionary<Type, MethodInfo> _cache = new();

If we were to look up all methods at this point, a simple Type key might still be sufficient. The worst case is a call with unrelated arguments, e.g. ToDTO<TeacherDTO, Student>() in which case we would happily match with the StudentDTO ToDTO(Student data) method, then fail trying to cast the return values. But that is the main issue with runtime lookups in the first place, so the only problematic thing here is that the exception would look weird to the caller — but one could still double-check the retrieved MethodInfo.

Now the only thing left to do is to call the actual method, and we can achieve this with this beauty:

(TDTO)methodInfo.Invoke(null, new object?[] { data })!;

The null cue here is to inform the Invoke method that we are operating on a static class, and the array we’re passing in contains the arguments.

To sum it up:

private static readonly ConcurrentDictionary<Type, MethodInfo> _cache = new();private static TDTO ToDTO<TDTO, TData>(TData data)
{
var inputType = typeof(TData);
var outputType = typeof(TDTO);
if (!_cache.TryGetValue(inputType, out var methodInfo))
{
methodInfo = GetMatchingMethodInfo(outputType, inputType);
if (methodInfo is null)
{
throw new InvalidOperationException($"No conversion from {inputType} to {outputType} was registered");
}

_cache.TryAdd(inputType, methodInfo);
}

return (TDTO)methodInfo.Invoke(null, new object?[] { data })!;
}
private static MethodInfo? GetMatchingMethodInfo(
Type outputType, Type inputType) =>
typeof(PeopleExtension)
.GetMethods(BindingFlags.Static | BindingFlags.Public)
.Where(method =>
method.Name.Equals(nameof(PeopleExtension.ToDTO)))
.Where(method => outputType == method.ReturnType)
.FirstOrDefault(method =>
inputType == method.GetParameters()
.SingleOrDefault()?.ParameterType);

Approach 3: Reflection with runtime compilation of Lambda expressions

The caching already removed the majority of concerns with the reflection-based approach, but we are still left with a dynamic invocation from the MethodInfo. One way to improve on this is to somehow get a delegate (i.e. function pointer) to call the method directly, and we’ll be doing this by wiring up a method call expression, then compiling it — at runtime.

The spot where we put the scalpel is right between having obtained a MethodInfo and storing it in the dictionary. If we were to write a specialized TDTO ToDTO<TDTO, TData>(TData data) method ourselves, knowing precisely that TDTO and TData are always referring to the specific types StudentDTO and Student, we would probably be doing something like this:

private TDTO ToDTO<TDTO, TData>(TData data) =>
(TDTO)ConvertWithTypesErased((object)student);
private object ConvertWithTypesErased(object data)
{
var student = (Student)data!; // unary conversion
var dto = PeopleExtension.ToDTO(student); // method call
return (object)dto; // unary conversion
}

First, we’d have to forget about the TData and StudentDTO type signatures by indirecting through an object cast. If we wouldn’t do it, CS0030 would be there to greet:

ReflectedWithDelegateTests.cs(71, 23): [CS0030] Cannot convert type 'TData' to 'ExtensionMethods70642141.Student'

We then call the actual method, and convert back to the generic TDTO type. The ConvertWithTypesErased method above follows a Func<object, object> signature, and this is exactly what we’ll be using for our cache:

private static readonly ConcurrentDictionary<Type, Func<object, object>> _cache = new();

In order to model the ConvertWithTypesErased method above using System.Linq.Expressions we will start by defining a function parameter of type object dubbed dataObj. We will use this twice, once for defining the method and once for calling it. We then convert from object to our known type and pass that into a method call expression.

var inputObject = Expression.Parameter(
typeof(object), "dataObj");
var inputCastToProperType = Expression.Convert(
inputObject, inputType);
var callExpr = Expression.Call(
null, methodInfo, inputCastToProperType);

When done, we convert the result back to object and wrap the entire tree into a lambda expression (reusing the aforemention dataObj parameter):

var castResultExpr = Expression.Convert(callExpr, typeof(object));var lambdaExpr = Expression.Lambda<Func<object, object>>(
castResultExpr, inputObject);

The only thing left to do here is to call Compile on the result and store it in the cache:

Func<object, object> toDto = lambdaExpr.Compile();
_cache.TryAdd(inputType, toDto);

From that point on, all we do is pass in our TData data value (as it trivially casts to object) and map the result back to TDTO when we return. To wrap it up:

private static readonly ConcurrentDictionary<Type, Func<object, object>> _cache = new();private TDTO ConvertToDTO<TDTO, TData>(TData data)
{
var inputType = typeof(TData);
var outputType = typeof(TDTO);
if (_cache.TryGetValue(inputType, out var toDto))
{
return (TDTO)toDto(data!);
}

var methodInfo = GetMatchingMethodInfo(outputType, inputType);
if (methodInfo is null)
{
throw new InvalidOperationException($"No conversion from {inputType} to {outputType} was registered");
}

toDto = CompileLambda<TDTO, TData>(inputType, methodInfo);
_cache.TryAdd(inputType, toDto);

return (TDTO)toDto(data!);
}
private static Func<object, object> CompileLambda(
Type inputType, MethodInfo methodInfo)
{
var inputObject = Expression.Parameter(
typeof(object), "dataObj");
var inputCastToProperType = Expression.Convert(
inputObject, inputType);
var callExpr = Expression.Call(
null, methodInfo, inputCastToProperType);
var castResultExpr = Expression.Convert(
callExpr, typeof(object));
var lambdaExpr = Expression.Lambda<Func<object, object>>(
castResultExpr, inputObject);
return lambdaExpr.Compile();
}

Still not good enough.

Approach 4: Compile-time Source Generation

The fundamental problem with the above approaches is that they all operate at runtime. There is nothing stopping us from typing up any wild combination of types only to figure out days later in production — or during testing, possibly— that this doesn’t actually work. There might be structural ways to resolve this (tricks like the one employed by the Visitor Pattern do away very nicely with their compile-time safe double indirection), but one thing is for sure: We would like to make sure right away that impossible combinations never compile.

Given the nature of the problem we will not be able to achieve this: The author originally asked for a method where TDTO and TData are strictly generic, and C# doesn’t always give us enough flexibility to work around that. There are still two things we can do here:

  • Build an analyzer that sanity-checks all calls and emits a compiler error where needed. I won’t do this here.
  • Build a source generator that moves the type lookup logic to compile time, rather than runtime.

Is the second approach slightly pointless and overkill? Yes. Let’s go!

We first create a new netstandard2.0 project hosting our source generator and reference the Microsoft.CodeAnalysis.CSharp and Microsoft.CodeAnalysis.Analyzers dependencies as private assets.

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>10</LangVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
</ItemGroup>

</Project>

We then reference this new project in our original one specifying both OutputItemType=”Analyzer” and ReferenceOutputAssembly=”false”:

<ItemGroup>
<ProjectReference
Include="..\SourceGenerators\SourceGenerators.csproj"
ReferenceOutputAssembly="false"
OutputItemType="Analyzer" />
</ItemGroup>
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>
$(BaseIntermediateOutputPath)Generated
</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

If we were to skip ReferenceOutputAssembly, our source generator would end up as a regular runtime dependency, which is not what we want. It does, after all, simply generate source. The EmitCompilerGeneratedFiles bit is not required, but helps a lot when debugging the generated code. The generated source files will end up in the obj directory of the project.

If you’re new to source generators, there’s a lot to untangle and I will only go about things briefly. There is excellent material both on YouTube (e.g. here) and in blogs, so take a deep breath and have a dive.

As far as we are concerned, we’ll be using these concepts:

  • ISourceGenerator and the [Generator] attribute; this is the bare minimum,
  • partial classes and methods,
  • ISyntaxReceiver to speed up compilation time,
  • MethodDeclarationSyntax syntax nodes and IMethodSymbol from the semantic model to find the main extension methods, as well as
  • InvocationExpressionSyntax syntax nodes to detect calls to the conversion method.

To begin with, we will create the following placeholder type:

namespace ExtensionMethods70642141;

public static partial class GenericPeopleConversion
{
public static partial TDTO ToDTO<TDTO, TData>(TData data);
}

This method implements the code requested by the original StackOverflow author; it is the job of the source generator to actually provide the partial implementation of this method.

The boilerplate for our source generator will look like the following: We implement the ISourceGenerator interface and tag the class with the [Generator] attribute. In itself, this generator would be called for every piece of code syntax observed by the compiler. This might end up being prohibitively slow for large code bases (i.e., not ours), so we assist the compiler by taking note only of very specific pieces of code using an ISyntaxReceiver that we register in the Initialize method of the source generator. The main job of our syntax receiver will be to find possible ToDTO extension methods, but we will go a step further by detecting calls to our TDTO ToDTO<TDTO, TData>(TData data) method as well. This way, we can generate dispatching code specific to the types actually used in the code, and not for everything that might occur. Here’s how it looks so far:

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace SourceGenerators;

[Generator]
public sealed class ToDTOGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() =>
new SyntaxReceiver());
}

public void Execute(GeneratorExecutionContext context)
{
// ...
context.AddSource("GenericPeopleConversion.Generated.cs",
source: "/* TODO */");
}

private sealed class SyntaxReceiver : ISyntaxReceiver
{
public HashSet<MethodDeclarationSyntax> CandidateMethods { get; } = new();
public HashSet<InvocationExpressionSyntax> CandidateInvocations { get; } = new();

public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
// TODO ...
}
}
}

The implementation of the syntax receiver benefits drastically from C# 8+’s pattern matching. TheOnVisitSyntaxNode method will be called by the compiler for each syntax element of the code, and we will be saving the interesting bits for later. As far as the ToDTO extension methods are concerned,

  • the syntax node must resemble a method declaration (MethodDeclarationSyntax); moreover,
  • the method declaration must have the identifier ToDTO,
  • it must have exactly one argument (the data bit) and
  • it must have at least one modifier (static)

When it comes to calls to our conversion method to be,

  • the syntax node must resemble a method call (InvocationExpressionSyntax) with exactly one argument (the data bit), and
  • it must resemble an access of the (static) class’ member ToDTO.
private sealed class SyntaxReceiver : ISyntaxReceiver
{
public HashSet<MethodDeclarationSyntax> CandidateMethods { get; } = new();
public HashSet<InvocationExpressionSyntax> CandidateInvocations { get; } = new();

public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
// Find candidates for "ToDTO" extension methods.
// We expect exactly one input parameter as
// well as a "static" modifier.
if (syntaxNode is MethodDeclarationSyntax
{
Identifier.Text: "ToDTO",
ParameterList.Parameters.Count: 1,
Modifiers:
{
Count: >= 1
} modifiers
} mds &&
modifiers.Any(st => st.ValueText.Equals("static")))
{
CandidateMethods.Add(mds);
}

// Likewise, the method invocations must be to a
// "ToDTO" method with exactly one argument.
if (syntaxNode is InvocationExpressionSyntax
{
ArgumentList.Arguments.Count: 1,
Expression: MemberAccessExpressionSyntax
{
Name.Identifier.ValueText: "ToDTO",
}
} ie)
{
CandidateInvocations.Add(ie);
}
}
}

I’ll be upfront: There is currently no sane way to debug any of this and not everything makes immediate sense. The best way I found here is to repeatedly run dotnet clean and dotnet build and write fake code (wrapped in /* */comment blocks) to be inspected in the obj directories — hence the EmitCompilerGeneratedFiles property mentioned earlier on.

With the syntax receiver in place, we can flesh out the Execute method of the source generator. I’ll show the code first, the explanation follows immediately:

public void Execute(GeneratorExecutionContext context)
{
var compilation = context.Compilation;
var syntaxReceiver = (SyntaxReceiver)context.SyntaxReceiver!;

// Fetch all ToDTO methods.
var extensionMethods = syntaxReceiver.CandidateMethods
.Select(methodDeclaration => compilation
.GetSemanticModel(methodDeclaration.SyntaxTree)
.GetDeclaredSymbol(methodDeclaration)!)
.Where(declaredSymbol => declaredSymbol.IsExtensionMethod)
.ToImmutableHashSet<IMethodSymbol>(SymbolEqualityComparer.Default);

// Fetch type type arguments of all calls to the ToDTO methods.
var usedTypeArguments = syntaxReceiver.CandidateInvocations
.Select(methodDeclaration => compilation
.GetSemanticModel(methodDeclaration.SyntaxTree)
.GetSymbolInfo(methodDeclaration).Symbol as IMethodSymbol)
.Where(symbol => symbol?.TypeParameters.Length == 2)
.Select(symbol =>
new InputOutputPair(
symbol!.TypeArguments[0], symbol.TypeArguments[1]))
.ToImmutableHashSet();

var code = GenerateConversionMethodCode(extensionMethods, usedTypeArguments);
context.AddSource("GenericPeopleConversion.Generated.cs", code);
}

As mentioned initially, the goal is of a source generator is to generate source code, which is exactly how the method ends. I will get into the GenerateConversionMethodCode method later on; at this point it’s only interesting to know that it returns the generated source code as a string.

As for the rest: Not everything makes sense on the syntax node level, so the first thing we do is obtaining semantic information from the syntax by getting the Compilation from the context and calling its GetSemanticModel method on every interesting node’s SyntaxTree.

For method declarations, we then grab the IMethodSymbol from said semantic model and check its IsExtensionMethod property; together with the syntax receiver, this ensures that we end up with exactly the methods we need. We pass both sets into the GenerateConversionMethodCode method, allowing it to see all possible conversion methods, as well as all their (actual) usages.

For the method invocations, things are a bit more complected, but follow the same approach: We get the semantic model, obtain the IMethodSymbol and then verify that the method we are calling has exactly two type parameters, namelyTDTO and TData. We stash away all type arguments, leaving us with a set of all used concreteTDTO-TData combinations. The InputOutputPair here is simply a readonly struct that uses SymbolEqualityComparer.Default internally:

private readonly struct InputOutputPair: IEquatable<InputOutputPair>
{
public InputOutputPair(
ITypeSymbol dtoType, ITypeSymbol dataType)
{
DtoType = dtoType;
DataType = dataType;
}

public ITypeSymbol DtoType { get; }
public ITypeSymbol DataType { get; }

public bool Equals(InputOutputPair other) =>
DtoType.Equals(other.DtoType,
SymbolEqualityComparer.Default) &&
DataType.Equals(other.DataType,
SymbolEqualityComparer.Default);

public override bool Equals(object? obj) =>
obj is InputOutputPair other && Equals(other);

public override int GetHashCode()
{
unchecked
{
return (SymbolEqualityComparer.Default
.GetHashCode(DtoType) * 397) ^
SymbolEqualityComparer.Default
.GetHashCode(DataType);
}
}
}

Note that HashCode.Combine isn’t available for netstandard2.0, so I’m going with Rider’s default implementation.

Now for the ugly bit: The GenerateConversionMethodCode method. In order to refer to the correct method and type names, we use a handful of helper methods that generate the fully qualified type and method names (Namespace.Type and Namespace.Type.Method) from the IMethodSymbol interfaces:

private static string GetMethodFullName(IMethodSymbol methodSymbol)
{
var methodReceiverType = methodSymbol.ReceiverType!;
return
$"{methodReceiverType.ContainingNamespace.Name}.{methodReceiverType.Name}.{methodSymbol.Name}";
}

private static string GetReturnTypeFullName(IMethodSymbol methodSymbol)
{
var returnTypeNamespace = methodSymbol.ReturnType
.ContainingNamespace.Name;
var returnTypeName = methodSymbol.ReturnType.Name;
return $"{returnTypeNamespace}.{returnTypeName}";
}

private static string GetParameterTypeFullName(
IMethodSymbol methodSymbol)
{
var parameterTypeNamespace = methodSymbol
.Parameters.Single()
.Type.ContainingNamespace.Name;
var parameterTypeName = methodSymbol
.Parameters.Single()
.Type.Name;
return $"{parameterTypeNamespace}.{parameterTypeName}";
}

With that in place, our GenerateConversionMethodCode method simply iterates all entries in the extensionMethods set, tests whether it is actually referenced by the code, obtains the type names and builds up the partial method by adding more and more if statements checking the types, then dispatching to the appropriate ToDTO method.

private static string GenerateConversionMethodCode(
ImmutableHashSet<IMethodSymbol> extensionMethods,
ImmutableHashSet<InputOutputPair> usedTypeArguments)
{
var sb = new StringBuilder();

sb.Append(@"
using System;

namespace ExtensionMethods70642141;

public static partial class GenericPeopleConversion {
public static partial TDTO ToDTO<TDTO, TData>(TData data)
{");

foreach (var methodSymbol in extensionMethods)
{
if (!usedTypeArguments.Contains(
new InputOutputPair(
methodSymbol.ReturnType,
methodSymbol.Parameters.Single().Type)))
{
continue;
}
var methodName = GetMethodFullName(methodSymbol);
var parameterType = GetParameterTypeFullName(methodSymbol);
var returnType = GetReturnTypeFullName(methodSymbol);

sb.AppendLine($@"
if (typeof(TData) == typeof({parameterType}) && typeof(TDTO) == typeof({returnType})) {{
return (TDTO)(object){methodName}(({parameterType})(object)data);
}}");
}

// TODO: Add an analyzer that prevents this from happening. :)
sb.Append(
@" throw new InvalidOperationException(""No method found to convert from type {typeof(TData)} to {typeof{TDTO}}"");");
sb.AppendLine(@"
}
}");

return sb.ToString();
}

It also throws an exception for good measure (I did promise it’s not going to help much!) to ensure no invalid combination is called — and reminds you to write an analyzer.

The only thing left to do now is to actually call the method:

using System;
using Xunit;

namespace ExtensionMethods70642141;

public class SourceGeneratedTests
{
[Fact]
public void Works()
{
var student = new Student { Name = "Student Name" };
var teacher = new Teacher { Name = "Teacher Name" };

var studentDto = GenericPeopleConversion.ToDTO<StudentDTO, Student>(student);
var teacherDto = GenericPeopleConversion.ToDTO<TeacherDTO, Teacher>(teacher);

Assert.Equal(student.Name, studentDto.Name);
Assert.Equal(teacher.Name, teacherDto.Name);
}

[Fact]
public void InvalidConversionFails()
{
var student = new Student { Name = "Student Name" };

var invalidCall = () => GenericPeopleConversion.ToDTO<TeacherDTO, Student>(student);

Assert.Throws<InvalidOperationException>(invalidCall);
}
}

and when used like this, the source generator creates an GenericPeopleConversion.Generated.cs of this content:

using System;

namespace ExtensionMethods70642141;

public static partial class GenericPeopleConversion
{
public static partial TDTO ToDTO<TDTO, TData>(TData data)
{
if (typeof(TData) == typeof(ExtensionMethods70642141.Teacher) && typeof(TDTO) == typeof(ExtensionMethods70642141.TeacherDTO)) {
return (TDTO)(object)ExtensionMethods70642141.PeopleExtension.ToDTO((ExtensionMethods70642141.Teacher)(object)data);
}

if (typeof(TData) == typeof(ExtensionMethods70642141.Student) && typeof(TDTO) == typeof(ExtensionMethods70642141.StudentDTO)) {
return (TDTO)(object)ExtensionMethods70642141.PeopleExtension.ToDTO((ExtensionMethods70642141.Student)(object)data);
}
throw new InvalidOperationException("No method found to convert from type {typeof(TData)} to {typeof{TDTO}}");
}
}

Comment out both calls to GenericPeopleConversion.ToDTO and the source generator creates an empty implementation:

public static partial class GenericPeopleConversion {
public static partial TDTO ToDTO<TDTO, TData>(TData data)
{
throw new InvalidOperationException("No method found to convert from type {typeof(TData)} to {typeof{TDTO}}");
}
}

And that’s how to convert between types in increasingly absurd ways. That said, writing an actual Analyzer isn’t going to be that much harder after what we just pulled off, so I’ll leave it as an exercise to the reader.

Approach #5: Using AutoMapper

Now arguably there are insights to be gained in the above experiments. That said, if we make our peace with runtime resolution, then AutoMapper is a superior solution to the problem; in the example below, automatic name-based mapping is applied, but that’s obviously configurable.

var configuration = new MapperConfiguration(cfg =>
{
cfg.CreateMap<Student, StudentDTO>();
cfg.CreateMap<Teacher, TeacherDTO>();
});

#if DEBUG
configuration.AssertConfigurationIsValid();
#endif

var mapper = configuration.CreateMapper();

var student = new Student { Name = "Student Name" };
var teacher = new Teacher { Name = "Teacher Name" };

var studentDto = _mapper.Map<StudentDTO>(student);
var teacherDto = _mapper.Map<TeacherDTO>(teacher);
var invalidCall = () => mapper.Map<TeacherDTO>(student);
Assert.Equal(student.Name, studentDto.Name);
Assert.Equal(teacher.Name, teacherDto.Name);
Assert.Throws<AutoMapperMappingException>(invalidCall);

As usual, use the right tool for the job.

Thanks for reading, stay safe, stay vaccinated and stay curious!

--

--