Rather than declaring a new delegate type for every callback signature, .NET provides three built-in generic delegate families that cover virtually every case: Action<T> (void return, up to 16 parameters), Func<T, TResult> (non-void return, up to 16 input parameters), and Predicate<T> (returns bool, exactly one parameter). These types are used throughout the .NET BCL and ASP.NET Core — LINQ operators accept Func<T, bool>, middleware accepts Func<HttpContext, Task>, and DI accepts Func<IServiceProvider, T> factory delegates.
Action — Void Callbacks
// Action — void return, 0 to 16 parameters
Action doNothing = () => { };
Action<string> log = msg => Console.WriteLine(msg);
Action<int, int> printSum = (a, b) => Console.WriteLine(a + b);
Action<string, int, bool> complex = (s, n, b) => DoComplexWork(s, n, b);
log("Application started"); // "Application started"
printSum(10, 20); // 30
// Action as a callback parameter
public void ProcessAll(IEnumerable<Post> posts, Action<Post> onEach)
{
foreach (var post in posts)
onEach(post);
}
ProcessAll(posts, p => Console.WriteLine(p.Title));
ProcessAll(posts, p => _cache.Invalidate(p.Id));
Action and Func are declared as generic types in the System namespace. Action<T1, T2, ..., T16> represents a method with up to 16 parameters that returns void. Func<T1, T2, ..., T16, TResult> represents a method with up to 16 parameters that returns TResult — the return type is always the last type argument. Func<string, int> means a function that takes a string and returns an int. Func<int, int, int> takes two ints and returns an int.app.Use(async (ctx, next) => { ... await next(ctx); ... }) — the middleware delegate accepts a RequestDelegate (which is essentially a Func<HttpContext, Task>). Building your own higher-order functions with Action/Func parameters enables flexible, reusable code without inheritance — a component of the “prefer composition over inheritance” principle.Predicate<T> and Func<T, bool> are compatible in terms of signature but are different delegate types — you cannot pass one where the other is expected without an explicit conversion. List<T>.FindAll(Predicate<T>) requires a Predicate<T>, while LINQ’s Where(Func<T, bool>) requires a Func<T, bool>. A lambda like p => p.IsPublished is compatible with both, but a stored delegate variable of type Predicate<Post> cannot be passed to Where() directly.Func — Functions with Return Values
// Func — returns a value, 0 to 16 input parameters + 1 return type
Func<int> random = () => new Random().Next(100);
Func<string, int> length = s => s.Length;
Func<int, int, int> multiply = (a, b) => a * b;
Func<Post, PostDto> toDto = p => new PostDto { Id = p.Id, Title = p.Title };
Console.WriteLine(length("Hello")); // 5
Console.WriteLine(multiply(6, 7)); // 42
// Higher-order function using Func
public static IEnumerable<TResult> Transform<T, TResult>(
IEnumerable<T> source,
Func<T, TResult> selector)
=> source.Select(selector);
var titles = Transform(posts, p => p.Title); // IEnumerable<string>
var dtos = Transform(posts, toDto); // IEnumerable<PostDto>
// Func as a factory (used in DI and caching)
Func<IServiceProvider, IEmailSender> emailFactory =
sp => new SmtpEmailSender(sp.GetRequiredService<SmtpSettings>());
builder.Services.AddScoped<IEmailSender>(emailFactory);
Pipeline Pattern with Func
// Chain Func delegates into a processing pipeline
public class Pipeline<T>
{
private readonly List<Func<T, T>> _steps = new();
public Pipeline<T> AddStep(Func<T, T> step) { _steps.Add(step); return this; }
public T Execute(T input)
=> _steps.Aggregate(input, (current, step) => step(current));
}
// Use the pipeline to process post content
var pipeline = new Pipeline<string>()
.AddStep(s => s.Trim())
.AddStep(s => s.ToLowerInvariant())
.AddStep(s => s.Replace(" ", "-"))
.AddStep(s => s.Length > 50 ? s[..50] : s);
string slug = pipeline.Execute(" Hello World, This is a Long Title! ");
// "hello-world,-this-is-a-long-title!"
Common Mistakes
Mistake 1 — Confusing Func parameter order (return type is LAST)
❌ Wrong — misreading the type signature:
Func<int, string> f; // takes string, returns int — WRONG mental model!
✅ Correct mental model: all type parameters before the last are inputs; the last is the output:
Func<int, string> f = n => n.ToString(); // takes int, returns string ✓
Mistake 2 — Using Predicate<T> where Func<T, bool> is required (or vice versa)
Create lambda expressions directly at the call site rather than storing as a typed delegate to avoid type incompatibility issues.