A generic delegate is a delegate type whose signature is parameterized by one or more type parameters, the same way a generic class or method is. The .NET Base Class Library ships three families you'll use constantly, Func<...>, Action<...>, and Predicate<T>, and you can declare your own when a named type makes the call site clearer. This chapter looks at all of these through the generics lens: how the type parameters connect to the signature, how variance from the _Covariance & Contravariance_ lesson applies, and how to pass and return these delegates in generic code.
What a delegate fundamentally is, how lambdas desugar, and how multicast invocation lists work belong to the _Delegates & Events_ section. This chapter assumes you've read that section and focuses on the generic side of the story.
A non-generic delegate names a single fixed signature. A generic delegate names a family of signatures, one for each combination of type arguments. The declaration looks almost identical to a non-generic delegate, with type parameters added to the name.
Read this from left to right. public delegate says "I'm declaring a delegate type." TResult is the return type. Transform is the delegate name. <T, TResult> lists the two type parameters. (T input) is the parameter list. The whole declaration says: "A Transform<T, TResult> is any method that takes one T and returns a TResult."
Once declared, the type can be closed at any pair of type arguments to get a concrete delegate type. Transform<Order, Receipt> means "a method that takes an Order and returns a Receipt." Transform<string, int> means "a method that takes a string and returns an int." These are two completely different types as far as the compiler is concerned, even though they share the same generic definition.
An E-Commerce example that uses Transform<Order, Receipt> to turn an order into a printable receipt:
The variable makeReceipt is typed as Transform<Order, Receipt>. The lambda o => new Receipt(...) matches that shape, so the assignment compiles. Calling makeReceipt(order) invokes the lambda and produces a Receipt.
You can also assign a regular named method to the delegate, as long as the signature lines up.
Receipts.Build has signature Receipt (Order), which matches Transform<Order, Receipt>. The assignment converts the method group into a delegate instance pointing at Build. We'll come back to this method group conversion later in the chapter.
The framework already ships generic delegate types that cover most callback shapes, so the right starting question for a new piece of code is "can I reuse Func, Action, or Predicate<T> instead of declaring my own?" These three families exist in System and are imported by default in .NET 6+ projects via global usings, so you can use them anywhere without an extra using directive.
Func<T,...,TResult>Func describes a method that returns a value. The last type parameter is always the return type, and the preceding type parameters are the input types. The BCL declares overloads from Func<TResult> (no inputs) through Func<T1, T2, ..., T16, TResult> (sixteen inputs).
The interesting part for this chapter is the variance annotations on Func. The framework declares it as Func<in T1, ..., in T16, out TResult>. The input parameters are marked in (contravariant) and the return type is marked out (covariant). The _Covariance & Contravariance_ lesson explains why those keywords mean what they do; here it's enough to know the practical consequence.
A Func<Customer, string> can be assigned to a variable of type Func<PremiumCustomer, object>, where PremiumCustomer derives from Customer and object is the base type of string. The input gets more specific, the output gets more general, and the assignment is type-safe. This is what in and out are buying you.
If you have to revisit why this works, the chapter on covariance and contravariance is the right reference. For day-to-day use, the rule of thumb is: Func lets you pass a more capable function (handles a wider input type, returns a more specific output) wherever a less capable one is expected.
Action<T,...>Action describes a method that returns void. Action (no parameters) through Action<T1, ..., T16> cover the same parameter range as Func minus the return type. They're declared with in on all type parameters, so they're contravariant in their inputs and have no return type to vary on.
The contravariance shows up the same way it does for Func. An Action<Customer> can be assigned to an Action<PremiumCustomer> because anything that handles a generic customer can certainly handle the more specific premium variant.
Predicate<T>Predicate<T> is the odd one out. It's a single, one-parameter delegate that returns bool: structurally equivalent to Func<T, bool>. It exists for historical reasons. Predicate<T> shipped with .NET Framework 2.0 in 2005, where it was used by methods like List<T>.Find, List<T>.RemoveAll, and Array.FindAll. Func<T, TResult> arrived later, in .NET 3.5, alongside LINQ. By then Predicate<T> was already entrenched in the BCL surface and couldn't be removed without breaking callers.
The signatures of Predicate<T> and Func<T, bool> are identical at the IL level, but the types are not interchangeable. You can't assign one to a variable of the other type directly. The implicit conversion does not exist.
The fix is to either reuse the lambda (which works for both types because lambdas convert to either delegate type) or wrap it:
For new code, Func<T, bool> is the more general choice because it composes with LINQ (Where, Any, All all take Func<T, bool>, not Predicate<T>). Use Predicate<T> only when calling a BCL method that requires it.
The lambda satisfies both shapes, but the delegate types themselves don't convert into each other. Which type you pick depends on which API you're feeding it to.
Func/ActionIf Func and Action already cover every signature you could need, why declare a custom generic delegate type at all? The answer comes down to readability and documentation.
A type like Func<Order, decimal, bool, decimal> is structurally correct but tells you nothing about what each parameter means. Is the bool whether the customer is a member? Whether the order is rush-delivery? Is the decimal parameter a tax rate or a discount? You have to read the method's documentation to find out. A custom delegate name encodes that meaning directly.
Three rules of thumb help with the choice.
| Situation | Pick |
|---|---|
| One-off internal callback, shape is obvious from context | Func / Action |
| Public API where the parameter names carry meaning | Custom delegate |
| Long parameter lists (3+ inputs) where positional reading is hard | Custom delegate |
| LINQ-style methods, framework consistency matters | Func / Action |
There's also a practical consequence to consider. Two different delegate types with identical signatures are not assignment-compatible without an explicit wrapper. If you publish a PricingRule<TOrder> type and a caller already has a Func<Order, decimal, bool, decimal> value, they can't pass it directly. They have to wrap it: (o, r, m) => existingFunc(o, r, m). This is a small cost to factor in before declaring a named type.
For internal code in a single project, lean on Func and Action. For public library APIs where the type name appears in IntelliSense and is read by users, custom delegate names earn their keep.
The most common place generic delegates appear is as parameters to generic methods. The classic shape is a "map" or "transform" method that takes a sequence of one type, a function from that type to another, and returns a sequence of the new type.
The second line prints the taxed prices as culture-invariant numbers because string.Join calls ToString() without a format specifier. To format them as currency, project through ToString("C") first:
With the formatting step the second line prints $53.99, $26.46, $214.92. Generic delegates pass through whatever type you give them; formatting is your responsibility at the call site.
Type inference is what makes Map(products, p => p.Name) work without specifying type arguments. The compiler sees that products is IEnumerable<Product>, so TIn must be Product. It then looks at p => p.Name, infers that p is a Product, and notes that p.Name returns a string, so TOut must be string. The full call is Map<Product, string>(...) even though we wrote neither type argument.
A second common shape is a filter, which uses Func<T, bool> (or Predicate<T> when calling List<T> methods).
The Filter method has one type parameter, T, and accepts a Func<T, bool> to decide which elements pass. The caller supplies a Func<Product, bool> and the compiler infers T as Product. This is essentially what Enumerable.Where does, modulo extra optimizations.
You can also accept multiple generic delegates in one method, which is how operations like fold or aggregate work. The standard Aggregate signature looks like:
Two type parameters, two generic delegate participations through Func<TAccumulate, TSource, TAccumulate>. The caller might write:
The seed 0m fixes TAccumulate as decimal. The lambda's parameters and return type all line up to decimal and Order, so the compiler infers TSource = Order. The whole call type-checks without any explicit type arguments.
Each non-static lambda that captures a variable from its enclosing scope allocates a closure object every time the surrounding method is called. In a hot path running millions of times, prefer a lambda that captures nothing (the compiler caches it as a static field) or assign the delegate to a field once and reuse it.
Methods don't just consume delegates, they can produce them too. A common pattern is a factory method that builds and returns a delegate, often closing over some configuration the caller supplies. When the factory itself is generic, the returned delegate carries that generic information.
Consider a sort-key extractor for products, where the caller picks which field to sort by. The factory takes the field selector and returns a comparison-ready delegate.
The KeySelector factory is trivial here (it just returns its argument), but it illustrates the shape: a generic method whose return type is a generic delegate type parameterized by the same type parameter. A more useful factory would do work, like composing two delegates or wrapping one with logging.
WithLogging takes a delegate and returns a new delegate with the same shape but added behavior. The returned lambda closes over fn and label, which is fine: closures are exactly how delegate factories carry configuration into the returned function.
There's a subtlety worth pointing out. The returned lambda is a new delegate instance every time WithLogging is called, and it captures fn and label, so the captured pair gets allocated on the heap. If you call WithLogging once and reuse the result, the allocation cost is paid once. If you call it inside a loop, you're allocating a fresh closure each iteration.
Returning a lambda that captures local variables allocates a closure object on each call to the factory. For a hot factory, cache the returned delegate in a field rather than rebuilding it.
A "method group" is the name of a method without the parentheses. When you write int.Parse (no parens, no arguments), you're referring to the method group, not invoking it. The compiler can convert a method group into a compatible delegate type without needing a lambda wrapper.
This shows up constantly in LINQ.
Enumerable.Select<TSource, TResult> takes a Func<TSource, TResult>. int.Parse has many overloads, but int.Parse(string) matches the shape Func<string, int>. The compiler picks that overload, builds a delegate that points at it, and passes it to Select. No lambda is written, and the code reads as "parse each one."
You can convert a method group to a custom generic delegate the same way.
Receipts.Format has signature string (int), which matches Transform<int, string>. The conversion is implicit and the assignment compiles.
Method group conversions used to allocate a fresh delegate object every time the conversion site was hit, which made them a small but real cost in tight loops. Starting with C# 11 (and the Roslyn changes that shipped with .NET 7), the compiler caches the delegate in a static field after the first use, so subsequent conversions return the same instance. The optimization is silent and transparent, and it removes a category of accidental allocations from hot paths.
Each method group conversion allocates a delegate the first time it's reached. In C# 11+, the compiler caches the resulting delegate in a static field for repeated conversions of the same method group, so the steady-state cost is one allocation, not one per call.
The story is different when the method group is an instance method. customer.SendEmail captures customer along with the method, so the resulting delegate has to hold a reference to that specific instance. The compiler can't share this delegate across different customer values, so each conversion allocates.
All delegate types in C#, generic or not, are multicast: a single delegate instance can hold an invocation list of more than one method, and invoking the delegate calls them in order. You combine delegates with + or += and remove them with - or -=. For Func-shaped delegates, invoking a multicast instance returns the value from the last method in the list, which is rarely what you want.
For this chapter, the relevant point is that generic delegate types inherit this behavior. There's no additional generics-specific syntax or rule; Action<T>, Func<T, TResult>, your custom Transform<T, TResult>, all support += and -= exactly the way non-generic delegates do.
To wrap the pieces, here's a small end-to-end example that uses all four delegate types we've covered: Predicate<T> for filtering, Func<T, TResult> for projection, Action<T> for side effects, and a custom Transform<T, TResult> for the final conversion.
Each delegate type has a job. Predicate<Product> filters. Func<Order, decimal> projects. Action<Customer> does side-effecting work. Transform<Order, Receipt> carries a domain-meaningful name where a Func<Order, Receipt> would have been technically equivalent but less self-documenting. The generic type parameters on each one carry the input and output types through the type system, so the compiler catches mistakes like passing a Customer to a function expecting an Order.
10 quizzes