docs/features/interceptors.md
Interceptors is a C# compiler feature, first shipped experimentally in .NET 8, with stable support in .NET 9.0.2xx SDK and later.
An interceptor is a method which can declaratively substitute a call to an interceptable method with a call to itself at compile time. This substitution occurs by having the interceptor declare the source locations of the calls that it intercepts. This provides a limited facility to change the semantics of existing code by adding new code to a compilation (e.g. in a source generator).
using System;
using System.Runtime.CompilerServices;
var c = new C();
c.InterceptableMethod(1); // L1: prints "interceptor 1"
c.InterceptableMethod(1); // L2: prints "other interceptor 1"
c.InterceptableMethod(2); // L3: prints "other interceptor 2"
c.InterceptableMethod(1); // prints "interceptable 1"
class C
{
public void InterceptableMethod(int param)
{
Console.WriteLine($"interceptable {param}");
}
}
// generated code
static class D
{
[InterceptsLocation(version: 1, data: "...(refers to the call at L1)")]
public static void InterceptorMethod(this C c, int param)
{
Console.WriteLine($"interceptor {param}");
}
[InterceptsLocation(version: 1, data: "...(refers to the call at L2)")]
[InterceptsLocation(version: 1, data: "...(refers to the call at L3)")]
public static void OtherInterceptorMethod(this C c, int param)
{
Console.WriteLine($"other interceptor {param}");
}
}
A method indicates that it is an interceptor by adding one or more [InterceptsLocation] attributes. These attributes refer to the source locations of the calls it intercepts.
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class InterceptsLocationAttribute(int version, string data) : Attribute
{
}
}
Any "ordinary method" (i.e. with MethodKind.Ordinary) can have its calls intercepted.
In addition to "ordinary" forms M() and receiver.M(), a call within a conditional access, e.g. of the form receiver?.M() can be intercepted. A call whose receiver is a pointer member access, e.g. of the form ptr->M(), can also be intercepted.
[InterceptsLocation] attributes included in source are emitted to the resulting assembly, just like other custom attributes.
File-local declarations of this type (file class InterceptsLocationAttribute) are valid and usages are recognized by the compiler when they are within the same file and compilation. A generator which needs to declare this attribute should use a file-local declaration to ensure it doesn't conflict with other generators that need to do the same thing.
The arguments to [InterceptsLocation] are:
The "version 1" data encoding is a base64-encoded string consisting of the following data:
SyntaxNode.Position) of the call in syntax.The location of the call is the location of the simple name syntax which denotes the interceptable method. For example, in app.MapGet(...), the name syntax for MapGet would be considered the location of the call. For a static method call like System.Console.WriteLine(...), the name syntax for WriteLine is the location of the call. If we allow intercepting calls to property accessors in the future (e.g obj.Property), we would also be able to use the name syntax in this way.
Roslyn provides an API GetInterceptableLocation(this SemanticModel, InvocationExpressionSyntax, CancellationToken) for inserting [InterceptsLocation] into generated source code. We recommend that source generators depend on this API in order to intercept calls. See https://github.com/dotnet/roslyn/issues/72133 for further details.
Conversion to delegate type, address-of, etc. usages of methods cannot be intercepted.
Interception can only occur for calls to ordinary member methods--not constructors, delegates, properties, local functions, operators, etc. Support for more member kinds may be added in the future.
Interceptors cannot be declared in generic types at any level of nesting.
Interceptors must either be non-generic, or have arity equal to the sum of the arity of the original method's arity and containing type arities. For example:
Grandparent<int>.Parent<bool>.Original<string>(1, false, "a"); // L1
class Grandparent<T1>
{
class Parent<T2>
{
public static void Original<T3>(T1 t1, T2 t2, T3 t3) { }
}
}
class Interceptors
{
[InterceptsLocation(1, "..(refers to call at L1)")]
public static void Interceptor<T1, T2, T3>(T1 t1, T2 t2, T3 t3) { }
}
When an interceptor is generic, the type arguments from the original containing types and method are passed as type arguments to the interceptor, from outermost to innermost. In the above scenario, the interceptor receives <int, bool, string> as type arguments. If the interceptor type parameters have constraints which are violated by these type arguments, a compile-time error occurs.
This substitution allows interceptors to use type parameters which aren't in scope at its declaration site.
using System.Runtime.CompilerServices;
class C
{
public static void InterceptableMethod<T1>(T1 t) => throw null!;
}
static class Program
{
public static void M<T2>(T2 t)
{
C.InterceptableMethod(t); // L1
}
}
static class D
{
[InterceptsLocation(1, "..(refers to call at L1)")]
public static void Interceptor1<T2>(T2 t) => throw null!;
}
When a call is intercepted, the interceptor and interceptable methods must meet the signature matching requirements detailed below:
this parameter.
this parameter, must have the same ref kinds and types.object and dynamic.string parameter, and the interceptor accepts a string? parameter.params modifiers are not required to match.scoped modifiers and [UnscopedRef] must be equivalent.[CallerLineNumber] are ignored on the interceptor of an intercepted call.
[UnscopedRef]. Such attributes are required to match across interceptable and interceptor methods.Arity does not need to match between intercepted and interceptor methods. In other words, it is permitted to intercept a generic method with a non-generic interceptor.
If more than one interceptor refers to the same location, it is a compile-time error.
If an [InterceptsLocation] attribute is found in the compilation which does not refer to the location of an explicit method call, it is a compile-time error.
An interceptor must be accessible at the location where interception is occurring.
An interceptor contained in a file-local type is permitted to intercept a call in another file, even though the interceptor is not normally visible at the call site.
This allows generator authors to avoid polluting lookup with interceptors, helps avoid name conflicts, and prevents use of interceptors in unintended positions from the interceptor author's point-of-view.
We may also want to consider adjusting behavior of [EditorBrowsable] to work in the same compilation.
An interceptor whose this parameter takes a struct by-reference can generally be used to intercept a struct instance method call, assuming the methods are compatible per Signature matching. This includes situations where the receiver must be implicitly captured to temp before the invocation, even if such capture is not permitted when the interceptor is called directly. See also 12.8.9.3 Extension method invocations in the standard.
using System.Runtime.CompilerServices;
struct S
{
public void Original() { }
}
static class Program
{
public static void Main()
{
new S().Original(); // L1: interception is valid, no errors.
new S().Interceptor(); // error CS1510: A ref or out value must be an assignable variable
}
}
static class D
{
[InterceptsLocation(1, "..(refers to call to 'Original()' at L1)")]
public static void Interceptor(this ref S s)
}
The reason we permit implicit receiver capture for the above intercepted call is: we want intercepting to be possible even when the interceptor author doesn't own the original receiver type. If we didn't do this, then intercepting Original() in the above example would only be possible by adding instance members to struct S.
Interceptors are treated like a post-compilation step in this design. Diagnostics are given for misuse of interceptors, but some diagnostics are only given in the command-line build and not in the IDE. There is limited traceability in the editor for which calls in a compilation are actually being intercepted.
GetInterceptorMethod(this SemanticModel, InvocationExpressionSyntax, CancellationToken) enables analyzers to determine if a call is being intercepted, and if so, which method is intercepting the call. See https://github.com/dotnet/roslyn/issues/72093 for further details.
To use interceptors, the user project must specify the property <InterceptorsNamespaces>. This is a list of namespaces which are allowed to contain interceptors.
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated;MyLibrary.Generated</InterceptorsNamespaces>
It's expected that each entry in the InterceptorsNamespaces list roughly corresponds to one source generator. Well-behaved components are expected to not insert interceptors into namespaces they do not own.
For compatibility, the property <InterceptorsPreviewNamespaces> can be used as an alias for <InterceptorsNamespaces>. If both properties have non-empty values, they will be concatenated together in the order $(InterceptorsNamespaces);$(InterceptorsPreviewNamespaces) when passed to the compiler.
During the binding phase, InterceptsLocationAttribute usages are decoded and the related data for each usage are collected in a ConcurrentSet on the compilation:
At this time, diagnostics are reported for the following conditions:
During the lowering phase, when a given BoundCall is lowered:
InterceptsLocationAttribute collected during the binding phase.BoundCall.BoundCall to use the receiver as the first argument of the call, "pushing" the other arguments forward, similar to the way it would have bound if the original call were to an extension method in reduced form.At this time, diagnostics are reported for the following conditions:
[InterceptsLocation], that is, multiple interceptors which intercept the same call.