Quack Quack: Duck-Typing in C# with Interceptors

9/12/2026
3 minute read

If it walks like a duck and it quacks like a duck - it is a duck. So duck it and make that work in C# thanks to interceptors!

TypeScript and where we wanna go#

So in TypeScript you can do something like:

class A {
  Do(): void { console.log("A.Do"); }
}

class B {
  Do(): void { console.log("B.Do"); }
}

function foo(a: { Do(): void }) {
  a.Do();
}

And I want to have something similar in C#:

public class A { public void Do() => Console.WriteLine("A.Do"); }
public class B { public void Do() => Console.WriteLine("B.Do"); }

public void Foo(??? a) => a.Do();

A and B have nothing in common. No shared base class, no shared interface. ??? is the shape we need to invent - something that lets Foo(new A()) and Foo(new B()) both compile and both call the right Do(), without touching A or B at all.

Also none of the following are allowed:

  • dynamic - even though that is the coolest ever
  • accepting object and use is or as cast stuff
  • not touching A or B (like adding a shared base - be it interface or abstract base class)

Interceptors#

Since .NET 8, C# has an experimental compiler feature called interceptors. A source generator can emit a method, slap [InterceptsLocation] on it pointing at a specific call site in your code, and the compiler will silently swap the call to go there instead - at zero runtime cost, because it's resolved entirely at compile time.

The way that normally works is a combination of a custom attribute (DuckType and DuckShape) that is emitted by the source code generator and the interceptor logic - so basically getting all callsides and "do the right thing".

So the usage wise we still something like an interface to "explain the shape" (or property):

[DuckShape]
public interface IDoable { void Do(); }

public static partial class Ops
{
    [DuckTyped]
    public static void Foo(IDoable a) => a.Do();
}

You write Foo(IDoable a) => a.Do() exactly like you'd want to. [DuckShape] marks IDoable as a structural contract rather than something you're expected to implement. [DuckTyped] tells the generator "this is the real logic, now go make it callable with anything that structurally matches."

I will link the whole generator/interceptor at the end of the article and will mainly show the interesting bids.

Anyway. Each call site resolves to this generic construct:

[InterceptsLocation(1, "…")]
public static void Interceptor_1(A value)
{
    Ops.Foo((IDoable)(new ShapeAdapter_IDoable_A(value)));
}

ShapeAdapter_IDoable_A is a tiny generated readonly struct that implements IDoable by forwarding straight to A:

internal readonly struct ShapeAdapter_IDoable_A : IDoable
{
    private readonly A _value;
    public ShapeAdapter_IDoable_A(A value) => _value = value;
    public void Do() => _value.Do();
}

That is the whole trick. It comes with a small dispatch cost (maybe inlining would help here a bit more).

So this perfectly resolves:

Ops.Foo(new A());   // "A.Do"
Ops.Foo(new B());   // "B.Do"

It works for properties too:

[DuckShape]
public interface INameable { string Name { get; set; } }

public class Person { public string Name { get; set; } = ""; }

[DuckTyped]
public static string Greet(INameable n) => $"Hello, {n.Name}!";

Greet(new Person { Name = "Steven" }); // "Hello, Steven!"

Conclusion#

So is it useful? Probably not - but that wasn't really the point here. So while the language doesn't allow it directly, one can "emulate" it to a certain extend.

Resources#

An error has occurred. This application may no longer respond until reloaded.Reload x