Open sandboxFocusImprove this doc

Writing generic interceptor templates

A template can declare type parameters, like any generic method. Generic templates have two main uses:

  • Reusing one template for methods of the same shape, whose parameters have different types, such as SaveOrder( Order order ) and SaveCustomer( Customer customer ).
  • Intercepting generic methods, such as Serialize<T>( T value ), whose call sites have different type arguments.

This article assumes that you know how to generate interceptors from templates. See Generating interceptors with T# templates.

Reusing a template for methods of the same shape

When the template declares a parameter, its type must be compatible with the type of the parameter of each intercepted method. To write one strongly typed template for methods whose parameters have different types, declare a compile-time type parameter. Use it as the type of the template parameter. Give the type of the parameter of each intercepted method in ForEachSite:

16.ForEachSite(
17    site => site
18        .RedirectToSynthesizedMethod( nameof(this.CheckNotNull) )
19        .WithArgs( new { T = site.InterceptedMethod.Parameters[0].Type } ) ) );

The template uses the type parameter as an ordinary type:

26public void CheckNotNull<[CompileTime] T>( T entity )
27{
28    if ( entity == null )
29    {
30        throw new ArgumentNullException( nameof(entity) );
31    }
32
33    meta.Proceed();
34}

One interceptor is generated for each intercepted method. In each of them, the template parameter takes the name of the parameter of the intercepted method, and nameof(entity) is written with this name.

A run-time type parameter cannot be used for this purpose, because Metalama binds run-time type parameters only to the type parameters of the intercepted method.

Example: Checking the arguments of several methods

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using System;
5
6namespace Doc.GenericSameShape;
7
8public class CheckRepositoryArgumentsAttribute : TypeAspect
9{
10    public override void BuildAspect( IAspectBuilder<INamedType> builder )
11        => builder.InterceptMethods(
12            calls => calls
13                .Type( typeof(Repository) )
14                .AllMethods()
16                .ForEachSite(
17                    site => site
18                        .RedirectToSynthesizedMethod( nameof(this.CheckNotNull) )
19                        .WithArgs( new { T = site.InterceptedMethod.Parameters[0].Type } ) ) );
21
22    // The template has a parameter of type T, which receives the type of the parameter of each
23    // intercepted method.
24    [Template]
26    public void CheckNotNull<[CompileTime] T>( T entity )
27    {
28        if ( entity == null )
29        {
30            throw new ArgumentNullException( nameof(entity) );
31        }
32
33        meta.Proceed();
34    }
36}
37
Source Code
1namespace Doc.GenericSameShape;
2
3public record Order( int Id );
4
5public record Customer( string Name );
6
7// The methods have the same shape: one parameter of a different type.
8public static class Repository
9{
10    public static void SaveOrder( Order order ) { }
11
12    public static void SaveCustomer( Customer customer ) { }
13}
14
15[CheckRepositoryArguments]
16public class ImportService
17{
18    public void Import( Order order, Customer customer )
19    {
20        Repository.SaveCustomer( customer );
21        Repository.SaveOrder( order );
22    }
23}
24
Transformed Code
1namespace Doc.GenericSameShape;
2
3public record Order(int Id);
4
5public record Customer(string Name);
6
7// The methods have the same shape: one parameter of a different type.
8public static class Repository
9{
10    public static void SaveOrder(Order order) { }
11
12    public static void SaveCustomer(Customer customer) { }
13}
14
15[CheckRepositoryArguments]
16public class ImportService
17{
18    public void Import(Order order, Customer customer)
19    {
20        MetalamaInterceptors.Repository_SaveCustomer_Interceptor(customer: customer);
21        MetalamaInterceptors.Repository_SaveOrder_Interceptor(order: order);
22    }
23}
24
using System;
using Doc.GenericSameShape;
internal static class MetalamaInterceptors
{
  internal static void Repository_SaveCustomer_Interceptor(Customer customer)
  {
    if (customer == null)
    {
      throw new ArgumentNullException(nameof(customer));
    }
    Repository.SaveCustomer(customer);
  }
  internal static void Repository_SaveOrder_Interceptor(Order order)
  {
    if (order == null)
    {
      throw new ArgumentNullException(nameof(order));
    }
    Repository.SaveOrder(order);
  }
}

Intercepting generic methods

When the intercepted method is generic, each call site has its own type arguments, for instance Serialize<int> and Serialize<string>. You choose how the generated interceptor handles them:

Goal Approach
Generate code that depends on the type argument, with the type known when the template is expanded. A template without type parameters, or with a compile-time type parameter. One interceptor is generated for each type argument.
Generate one interceptor for all the type arguments. A template with a run-time type parameter. The interceptor is generic.

Specializing the interceptor for each type argument

By default, when the template has no type parameter, Metalama generates one interceptor for each combination of type arguments. The call sites with the same type arguments share it. In the template, meta.Interception.Method.InterceptedMethod is constructed with the type arguments of the call sites, so the template can read them at compile time.

For instance, the following template reads the type argument of the call sites:

24var typeArgument = meta.Interception.Method.InterceptedMethod.TypeArguments[0];

Example: One interceptor per type argument

In the following example, the two calls with an int share one interceptor, and the call with a string gets another one.

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using System;
5
6namespace Doc.GenericSpecialized;
7
8public class TraceSerializationAttribute : TypeAspect
9{
10    public override void BuildAspect( IAspectBuilder<INamedType> builder )
11        => builder.InterceptMethods(
12            calls => calls
13                .Type( typeof(Serializer) )
14                .Methods( nameof(Serializer.Serialize) )
15                .RedirectToSynthesizedMethod( nameof(this.Trace) ) );
16
17    // The template has no type parameter, so one method is generated for each type argument
18    // of the call sites.
19    // The type argument is known when the template is expanded.
20    [Template]
21    public dynamic? Trace()
22    {
24        var typeArgument = meta.Interception.Method.InterceptedMethod.TypeArguments[0];
26        Console.WriteLine( $"Serializing a value of type {typeArgument.ToDisplayString()}." );
27
28        return meta.Proceed();
29    }
30}
31
Source Code
1namespace Doc.GenericSpecialized;
2
3[TraceSerialization]
4public class Exporter
5{
6    public string ExportNumber( int value ) => Serializer.Serialize( value );
7
8    public string ExportCount( int value ) => Serializer.Serialize( value );
9
10    public string ExportName( string value ) => Serializer.Serialize( value );
11}
12
13public static class Serializer
14{
15    public static string Serialize<T>( T value ) => value?.ToString() ?? "null";
16}
17
Transformed Code
1namespace Doc.GenericSpecialized;
2
3[TraceSerialization]
4public class Exporter
5{
6    public string ExportNumber(int value) => MetalamaInterceptors.Serializer_Serialize_Interceptor(value: value);
7
8    public string ExportCount(int value) => MetalamaInterceptors.Serializer_Serialize_Interceptor(value: value);
9
10    public string ExportName(string value) => MetalamaInterceptors.Serializer_Serialize_Interceptor1(value: value);
11}
12
13public static class Serializer
14{
15    public static string Serialize<T>(T value) => value?.ToString() ?? "null";
16}
17
using System;
using Doc.GenericSpecialized;
internal static class MetalamaInterceptors
{
  internal static string Serializer_Serialize_Interceptor(int value)
  {
    Console.WriteLine("Serializing a value of type int.");
    return Serializer.Serialize(value);
  }
  internal static string Serializer_Serialize_Interceptor1(string value)
  {
    Console.WriteLine("Serializing a value of type string.");
    return Serializer.Serialize(value);
  }
}

Using the type argument in compile-time code

To use the type argument as a type in the template, declare a compile-time type parameter, marked with [CompileTime], and give its value with WithArgs. Because the type argument differs for each call site, give it in ForEachSite, from InterceptedMethod. The template can then generate different code for each type, for instance a null check only for reference types.

For instance, the following registration gives the type argument of each call site:

16.ForEachSite(
17    site => site
18        .RedirectToSynthesizedMethod( nameof(this.Validate) )
19        .WithArgs( new { T = site.InterceptedMethod.TypeArguments[0] } ) ) );

The template generates a null check only for reference types:

26public dynamic? Validate<[CompileTime] T>()
27{
28    if ( meta.CompileTime( typeof(T).IsValueType ) )
29    {
30        Console.WriteLine( $"Serializing a value of type {typeof(T).Name}." );
31    }
32    else
33    {
34        if ( meta.Interception.Method.ArgumentParameters[0].Value == null )
35        {
36            throw new ArgumentNullException( "value" );
37        }
38    }
39
40    return meta.Proceed();
41}

Example: Different code for value types and reference types

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using System;
5
6namespace Doc.GenericCompileTimeTypeArgument;
7
8public class ValidateSerializationAttribute : TypeAspect
9{
10    public override void BuildAspect( IAspectBuilder<INamedType> builder )
11        => builder.InterceptMethods(
12            calls => calls
13                .Type( typeof(Serializer) )
14                .Methods( nameof(Serializer.Serialize) )
16                .ForEachSite(
17                    site => site
18                        .RedirectToSynthesizedMethod( nameof(this.Validate) )
19                        .WithArgs( new { T = site.InterceptedMethod.TypeArguments[0] } ) ) );
21
22    // T is a compile-time type parameter: the template generates different code for each
23    // type argument.
24    [Template]
26    public dynamic? Validate<[CompileTime] T>()
27    {
28        if ( meta.CompileTime( typeof(T).IsValueType ) )
29        {
30            Console.WriteLine( $"Serializing a value of type {typeof(T).Name}." );
31        }
32        else
33        {
34            if ( meta.Interception.Method.ArgumentParameters[0].Value == null )
35            {
36                throw new ArgumentNullException( "value" );
37            }
38        }
39
40        return meta.Proceed();
41    }
43}
44
Source Code
1namespace Doc.GenericCompileTimeTypeArgument;
2
3[ValidateSerialization]
4public class Exporter
5{
6    public string ExportNumber( int value ) => Serializer.Serialize( value );
7
8    public string ExportName( string? value ) => Serializer.Serialize( value );
9}
10
11public static class Serializer
12{
13    public static string Serialize<T>( T value ) => value?.ToString() ?? "null";
14}
15
Transformed Code
1namespace Doc.GenericCompileTimeTypeArgument;
2
3[ValidateSerialization]
4public class Exporter
5{
6    public string ExportNumber(int value) => MetalamaInterceptors.Serializer_Serialize_Interceptor(value: value);
7
8    public string ExportName(string? value) => MetalamaInterceptors.Serializer_Serialize_Interceptor1(value: value);
9}
10
11public static class Serializer
12{
13    public static string Serialize<T>(T value) => value?.ToString() ?? "null";
14}
15
using System;
using Doc.GenericCompileTimeTypeArgument;
internal static class MetalamaInterceptors
{
  internal static string Serializer_Serialize_Interceptor(int value)
  {
    Console.WriteLine($"Serializing a value of type {typeof(int).Name}.");
    return Serializer.Serialize(value);
  }
  internal static string Serializer_Serialize_Interceptor1(string? value)
  {
    if (value == null)
    {
      throw new ArgumentNullException("value");
    }
    return Serializer.Serialize<string?>(value);
  }
}

Sharing one generic interceptor

When the template declares a run-time type parameter, Metalama binds it to the type parameter of the intercepted method, and the generated interceptor is generic. All the call sites share it, whatever their type arguments. The template uses the type parameter as an ordinary type, for instance in typeof(T).

For instance, the following template has a run-time type parameter T, which binds to the type parameter of Serializer.Serialize<T>:

21public string Trace<T>( T value )

Example: One generic interceptor

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using System;
5
6namespace Doc.GenericTemplateShared;
7
8public class TraceSerializationAttribute : TypeAspect
9{
10    public override void BuildAspect( IAspectBuilder<INamedType> builder )
11        => builder.InterceptMethods(
12            calls => calls
13                .Type( typeof(Serializer) )
14                .Methods( nameof(Serializer.Serialize) )
15                .RedirectToSynthesizedMethod( nameof(this.Trace) ) );
16
17    // T is a run-time type parameter, so the generated method is generic and all call sites
18    // share it.
19    [Template]
21    public string Trace<T>( T value )
23    {
24        Console.WriteLine( $"Serializing a {typeof(T).Name}." );
25
26        return meta.Proceed()!;
27    }
28}
29
Source Code
1namespace Doc.GenericTemplateShared;
2

3[TraceSerialization]
4public class Exporter
5{
6    public string ExportNumber( int value ) => Serializer.Serialize( value );
7
8    public string ExportName( string value ) => Serializer.Serialize( value );
9
10    public string ExportDate( System.DateTime value ) => Serializer.Serialize( value );
11}
12
13public static class Serializer
14{
15    public static string Serialize<T>( T value ) => value?.ToString() ?? "null";
16}
17
Transformed Code
1namespace Doc.GenericTemplateShared;
2
3[TraceSerialization]
4public class Exporter
5{
6    public string ExportNumber(int value) => MetalamaInterceptors.Serializer_Serialize_Interceptor(value: value);
7
8    public string ExportName(string value) => MetalamaInterceptors.Serializer_Serialize_Interceptor(value: value);
9
10    public string ExportDate(System.DateTime value) => MetalamaInterceptors.Serializer_Serialize_Interceptor(value: value);
11}
12
13public static class Serializer
14{
15    public static string Serialize<T>(T value) => value?.ToString() ?? "null";
16}
17
using System;
using Doc.GenericTemplateShared;
internal static class MetalamaInterceptors
{
  internal static string Serializer_Serialize_Interceptor<T>(T value)
  {
    Console.WriteLine($"Serializing a {typeof(T).Name}.");
    return Serializer.Serialize(value);
  }
}

Type parameters of the calling type

When a call site uses a type parameter of the calling type, for instance a call to List<T>.Add in a class Repository<T>, the generated class cannot name T. Metalama then adds T as a type parameter of the generated interceptor, and the call site passes it.

Example: A type parameter of the calling type

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using System;
5using System.Collections.Generic;
6
7namespace Doc.GenericLiftedTypeParameter;
8
9public class TraceListChangesAttribute : TypeAspect
10{
11    public override void BuildAspect( IAspectBuilder<INamedType> builder )
12        => builder.InterceptMethods(
13            calls => calls
14                .Type( typeof(List<>) )
15                .Methods( nameof(List<object>.Add) )
16                .RedirectToSynthesizedMethod( nameof(this.Trace) ) );
17
18    // The generated class cannot name the type parameter T of Repository<T>,
19    // so T becomes a type parameter of the generated method.
20    [Template]
21    public dynamic? Trace()
22    {
23        var itemType = meta.Interception.Method.InterceptedMethod.Parameters[0].Type;
24        Console.WriteLine( $"Adding a {itemType.ToTypeOfExpression().Value!.Name}." );
25
26        return meta.Proceed();
27    }
28}
29
Source Code
1using System.Collections.Generic;
2
3namespace Doc.GenericLiftedTypeParameter;
4
5// The call to List<T>.Add uses the type parameter T of the calling type.
6[TraceListChanges]
7public class Repository<T>
8{
9    private readonly List<T> _items = new();
10
11    public void Add( T item ) => this._items.Add( item );
12}
13
Transformed Code
1using System.Collections.Generic;
2
3namespace Doc.GenericLiftedTypeParameter;
4
5// The call to List<T>.Add uses the type parameter T of the calling type.
6[TraceListChanges]
7public class Repository<T>
8{
9    private readonly List<T> _items = new();
10
11    public void Add(T item) => MetalamaInterceptors.List_Add_Interceptor(this._items, item: item);
12}
13
using System;
using System.Collections.Generic;
internal static class MetalamaInterceptors
{
  internal static void List_Add_Interceptor<T>(List<T> receiver, T item)
  {
    Console.WriteLine($"Adding a {typeof(T).Name}.");
    receiver.Add(item);
  }
}

Adding a type parameter

When the intercepted method is not generic, but its parameter has a general type such as object, make the interceptor generic yourself:

  1. In a Configure delegate, add a type parameter with AddTypeParameter.
  2. Use it as the type of the parameter.
  3. Give its type argument for each call site with SetTypeArgument, for instance the type of the argument.

For instance:

22var typeParameter = method.Method.AddTypeParameter( "TValue" );
23method.Method.Parameters[0].Type = typeParameter;
24method.SetTypeArgument( typeParameter, method.Context.Arguments[0].Type! );

The call sites then share one generic interceptor, and the arguments of value types are no longer boxed at the call site. The template reads the added type parameter through meta.Target.Method.TypeParameters. The type arguments must satisfy the constraints of the type parameter.

Example: Avoiding boxing at the call site

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using System;
5
6namespace Doc.GenericAddedTypeParameter;
7
8public class TraceLoggingAttribute : TypeAspect
9{
10    public override void BuildAspect( IAspectBuilder<INamedType> builder )
11        => builder.InterceptMethods(
12            calls => calls
13                .Type( typeof(Log) )
14                .Methods( nameof(Log.Write) )
15                .RedirectToSynthesizedMethod( nameof(this.Trace) )
16                .Configure(
17                    method =>
18                    {
19                        // Make the parameter generic, and pass the type of the argument
20                        // of each call site.
22                        var typeParameter = method.Method.AddTypeParameter( "TValue" );
23                        method.Method.Parameters[0].Type = typeParameter;
24                        method.SetTypeArgument( typeParameter, method.Context.Arguments[0].Type! );
26                    } ) );
27
28    [Template]
29    public dynamic? Trace()
30    {
31        var valueType = meta.Target.Method.TypeParameters[0];
32        Console.WriteLine( $"Logging a {valueType.ToTypeOfExpression().Value!.Name}." );
33
34        return meta.Proceed();
35    }
36}
37
Source Code
1using System;
2
3namespace Doc.GenericAddedTypeParameter;
4
5[TraceLogging]
6public class OrderService
7{
8    public void Place( int quantity, string product )
9    {
10        Log.Write( quantity );
11        Log.Write( product );
12    }
13}
14
15public static class Log
16{
17    // The parameter is an object, so a value type is boxed at the call site.
18    public static void Write( object value ) => Console.WriteLine( value );
19}
20
21internal class Program
22{
23    private static void Main() => new OrderService().Place( 3, "Book" );
24}
25
Transformed Code
1using System;
2
3namespace Doc.GenericAddedTypeParameter;
4
5[TraceLogging]
6public class OrderService
7{
8    public void Place(int quantity, string product)
9    {
10        MetalamaInterceptors.Log_Write_Interceptor(value: quantity);
11        MetalamaInterceptors.Log_Write_Interceptor(value: product);
12    }
13}
14
15public static class Log
16{
17    // The parameter is an object, so a value type is boxed at the call site.
18    public static void Write(object value) => Console.WriteLine(value);
19}
20
21internal class Program
22{
23    private static void Main() => new OrderService().Place(3, "Book");
24}
25
using System;
using Doc.GenericAddedTypeParameter;
internal static class MetalamaInterceptors
{
  internal static void Log_Write_Interceptor<TValue>(TValue value)
  {
    Console.WriteLine($"Logging a {typeof(TValue).Name}.");
    Log.Write((object)value);
  }
}
Logging a Int32.
3
Logging a String.
Book

Rules for type parameters

  • The type parameters that Metalama derives from the intercepted method or from the calling type cannot be renamed, but a Configure delegate can add constraints to them.
  • The type parameters added by a Configure delegate come after the derived ones, and each of them must receive a type argument at every call site.
  • The type arguments of added type parameters are not part of the comparison that decides which call sites share an interceptor. See Interceptor deduplication rules.