Some days ago I wrote about duck-typing in C# with interceptors. Back then it was a proof-of-concept with a lot of rough edges. Now it is a real library: IfItQuacks.
The idea in 30 seconds#
Declare an interface, mark your method with [DuckTyped] and pass in anything that fits - no base class, no implements, no attributes on the types themselves:
using IfItQuacks;
public interface INamed
{
string Name { get; }
}
public class Person { public string Name => "Steven"; }
public class Mallard { public string Name => "Donald"; }
public partial class Greeter
{
[DuckTyped]
public string Greet(INamed first, INamed second, string greeting = "Hello") =>
$"{greeting}, {first.Name} and {second.Name}!";
}
new Greeter().Greet(new Person(), new Mallard()); // Hello, Steven and Donald!
Neither Person nor Mallard knows about INamed. The source generator checks at compile time that both have a matching Name and routes the call through a small generated adapter. No reflection, no dynamic. If it doesn't fit, you get an analyzer error.
If you want to hold on to the duck, convert it explicitly:
List<INamed> names = [Duck.As<INamed>(new Person()), Duck.As<INamed>(new Mallard())];
Highlights#
Here is the short tour. The documentation has the long one.
Anonymous types are ducks#
This is my favorite one. Anonymous types are object literals, basically the same thing TypeScript hands you and they quack just fine:
new Greeter().Greet(new { Name = "Steven" }, new { Name = "Donald" });
Basically, you can write your tests with that. "Instant Stubs"!
public interface IPerson
{
string Name { get; }
int Age { get; }
}
public partial class AgeChecker
{
[DuckTyped]
public bool IsAdult(IPerson person) => person.Age >= 18;
}
[Test]
public void Should_detect_adults()
{
var checker = new AgeChecker();
Assert.That(checker.IsAdult(new { Name = "Steven", Age = 90 }), Is.True);
Assert.That(checker.IsAdult(new { Name = "Baby Duck", Age = 1 }), Is.False);
}
Or:
IPerson stub = Duck.As<IPerson>(new { Name = "Donald", Age = 90 });
List<IPerson> family =
[
Duck.As<IPerson>(new { Name = "Huey", Age = 10 }),
Duck.As<IPerson>(new { Name = "Dewey", Age = 10 }),
Duck.As<IPerson>(new { Name = "Louie", Age = 10 }),
];
Identity is preserved#
Adapters forward Equals, GetHashCode and ToString to the wrapped instance, so they behave in dictionaries and sets. And you can get the original back:
var person = new Person();
Duck.As<INamed>(person).Equals(Duck.As<INamed>(person)); // true
Duck.Unwrap(Duck.As<INamed>(person)) is Person; // true
And more#
- Any interface works, including framework ones:
Duck.As<IDisposable>(x),IEnumerable<T>parameters, ... - Interface parameters that do not need duck typing (an
ILogger, say) keep accepting implementations,nulland default values - Generic interfaces (
IContainer<T>) and generic[DuckTyped]methods with inferred type arguments - Events, indexers and default interface members
- Delegates, lambdas and method groups for single-method interfaces
static abstractmembers and operators via duck-typed constraints (where T : IAddable<T>) - generic math included- Instance and static methods,
ref/out,params, default values and named arguments - Zero setup: install the package, no project file changes
Resources#
- Repository: https://github.com/linkdotnet/IfItQuacks
- Documentation: https://linkdotnet.github.io/IfItQuacks/
- How does it work? - including what gets allocated
- Known limitations
- The original blog post: Quack Quack: Duck-Typing in C# with Interceptors