C# Action Delegate: Action, Action<T> and When to Use It
A ready-made delegate type for "do something with these arguments, return nothing" — so you almost never write your own void delegate again.
In the last two lessons, you wrote public delegate void Notify(string message); by hand. Now imagine you need a similar delegate that takes two ints instead. And another that takes no parameters at all. And another that takes a string and a bool. Are you really going to declare a new delegate type every single time?
The people who designed the .NET Base Class Library asked the same question — and the answer was no. They built a small family of generic delegate types that cover almost every shape you'll ever need, so you don't have to declare your own. The first and simplest of that family is Action.
What Is It?
The Simple Explanation
Action is a built-in delegate type that represents "a method that takes some arguments and returns nothing" — exactly the shape you'd otherwise write a custom void-returning delegate for. It comes in several generic flavors so it can match methods with different numbers of parameters.
The Technical Definition
Action is defined in the System namespace as a family of generic delegates, from zero parameters up to sixteen:
public delegate void Action();
public delegate void Action<T>(T obj);
public delegate void Action<T1, T2>(T1 arg1, T2 arg2);
public delegate void Action<T1, T2, T3>(T1 arg1, T2 arg2, T3 arg3);
// ...continues up to Action<T1, ..., T16>
Every Action variant has one thing in common: it always returns void. The number after "Action" in your head (Action, Action<T>, Action<T1,T2>...) simply tells you how many parameters it takes, and of what types.
Writing Your Own Delegate
public delegate void Notify(string s);- A brand-new named type, just for this one signature
- Repeat this for every new signature you need
Using Action
Action<string> notify = ...;- No new type declaration at all
- Works for any parameter list up to 16 types
Why Does It Exist?
The Problem — Delegate Type Explosion
Before generic delegates existed in .NET, every distinct method signature you wanted to pass around needed its own hand-declared delegate type. Two codebases solving the same kind of problem (say, "run this action, then that action") would each invent their own, incompatible delegate void SomeCallback(...) types — impossible to share between libraries.
The Solution — One Family, Parameterized by Generics
Generics (which you covered in the previous module) let the BCL define Action<T> once, and have it work for a method taking a string, a method taking an int, a method taking a Customer — anything. Because Action lives in the BCL, every .NET library, every piece of your own code, and every third-party package speaks the same "language" for void-returning callbacks.
Action doesn't do anything a custom delegate couldn't do. Its value is standardization — one shared vocabulary for "a callback with no return value," so you (and everyone else) stop reinventing that vocabulary project after project.
Big Picture
How It Works
- Skip writing
delegate void ...entirely —Actionalready exists inSystem.
Action greet = PrintGreeting; // no parameters
Action<string> log = LogMessage; // one parameter
Action<string, int> report = LogWithLevel; // two parameters
greet();
log("Started processing.");
report("WARN", 3);
- Everything from lessons 096–097 still applies —
Actionis multicast too, so+=/-=work the same way.
Simple Example
using System;
class Program
{
static void PrintGreeting() => Console.WriteLine("Hello!");
static void LogMessage(string message) => Console.WriteLine($"[LOG] {message}");
static void LogWithLevel(string message, int level) =>
Console.WriteLine($"[LEVEL {level}] {message}");
static void Main()
{
Action greet = PrintGreeting;
Action log = LogMessage;
Action report = LogWithLevel;
greet(); // Hello!
log("Processing started."); // [LOG] Processing started.
report("Disk almost full", 2); // [LEVEL 2] Disk almost full
// Multicast still works
Action combined = LogMessage;
combined += msg => Console.WriteLine($"[AUDIT] {msg}");
combined("Order saved."); // both handlers run
}
}
Code → Meaning → Result: No custom delegate types were declared anywhere in this file. Action, Action<string>, and Action<string, int> — all supplied by the BCL — cover every shape used here, and they combine with += exactly like the custom delegates from the previous lessons.
Real-World Example
A common pattern: a retry helper that runs an operation and retries it on failure, reporting each attempt through a caller-supplied logging action.
using System;
public static class Retrier
{
public static void RunWithRetry(Action operation, int maxAttempts, Action onAttemptFailed)
{
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
operation();
return; // success — stop retrying
}
catch (Exception ex)
{
onAttemptFailed($"Attempt {attempt} failed: {ex.Message}");
if (attempt == maxAttempts)
throw; // out of retries — let the caller handle it
}
}
}
}
class Program
{
static void Main()
{
int callCount = 0;
Retrier.RunWithRetry(
operation: () =>
{
callCount++;
if (callCount < 3)
throw new InvalidOperationException("Temporary network error");
Console.WriteLine("Operation succeeded.");
},
maxAttempts: 5,
onAttemptFailed: message => Console.WriteLine($"Retry log: {message}")
);
}
}
Retrier.RunWithRetry knows nothing about networking, databases, or files — it only knows "run this Action, and call this other Action<string> when something goes wrong." That's what makes it reusable across every kind of operation your application performs.
Analogy
Before Action, every "void callback" was like a custom-shaped plug — you needed a matching custom-shaped outlet (delegate type) for every device (method signature). Action and its generic siblings are like a universal outlet: as long as your device fits the number and type of prongs (parameters), it plugs right in — no custom adapter required.
Under the Hood
Action and Action<T> are ordinary delegate types, generated once by Microsoft and shipped in System.Private.CoreLib — there's no special-case compiler magic. They still derive from MulticastDelegate, still store a target-plus-method-pointer pair per invocation-list entry, and still get combined by Delegate.Combine under +=, exactly as covered in the previous lesson. The only thing "generic" about them is that the JIT compiler generates specialized native code per closed generic type (e.g., Action<int> vs Action<string>) the first time each is used — the same generic specialization mechanism from the Generics module, applied here to a delegate type instead of a class.
Common Confusion
1. "Action<T> returns a T" — no, it never returns anything
It's tempting to read Action<string> as "returns a string" because of the angle brackets. It doesn't — every type parameter on Action is a parameter type. Action is always void. If you need a return value, you need Func (next lesson).
2. Custom delegates aren't obsolete
Action covers the overwhelming majority of cases, but a custom delegate type still communicates intent better in some APIs (a named type like EventHandler or a domain-specific delegate reads more clearly than an anonymous Action<object, EventArgs>). You'll see this trade-off again with events.
Common Mistakes
Mistake 1 — Reaching for Action when you actually need a result
Passing an Action<int> and trying to "return" a value by writing it to a captured outer variable, instead of just using Func<int, TResult>.
If the caller needs a value back, use Func — covered next. Reach for Action only when the operation's whole point is a side effect (printing, saving, logging), not a computed result.
Mistake 2 — Losing readability with too many type parameters
Action<string, int, bool, decimal, DateTime> is technically legal, but the call site becomes a guessing game about what each positional argument means.
Beyond two or three parameters, consider a small record or class to group related values, and keep the Action to one or two parameters.
When Should I Use It?
- Any time you need a callback or piece of injected behavior that performs a side effect and doesn't need to return a value — logging, notifying, updating state.
- As a method parameter type, instead of declaring a one-off custom delegate.
- Skip it when a named, self-documenting delegate type genuinely communicates intent better in a public API (this is why
EventHandlerstill exists, rather than everything using bareAction).
Mental Model
Action = "do this, give nothing back."The type parameters (
<T1, T2, ...>) describe the inputs, never a return value.It's a delegate you don't have to declare — the BCL already declared it for you.
Key Takeaway
Action,Action<T>,Action<T1,T2>, ... are built-in generic delegate types in theSystemnamespace, always returningvoid.- They exist so you rarely need to declare a custom delegate type just to describe "a callback that returns nothing."
- They're ordinary
MulticastDelegates underneath — everything about invocation lists,+=/-=, and sequential invocation from lesson 097 applies unchanged. - Use
Actionfor side-effect operations; when you need a computed result back, you wantFuncinstead.
Check Your Understanding
You've seen how Action replaces most custom void delegates. Let's check your understanding.
1. What does every variant of Action have in common?
Show answer
Correct: B
Why B is correct: Every Action variant, regardless of how many type parameters it has, always returns void. The type parameters only ever describe input parameters.
Why A is incorrect: Action (no parameters) through Action<T1,...,T16> support varying numbers of parameters.
Why C is incorrect: Action works with static methods, instance methods, and lambdas equally.
Why D is incorrect: Action is a MulticastDelegate just like custom delegates — it fully supports +=/-=.
Reinforcement: The number of type parameters on Action tells you the parameter list, never a return type.
2. Which built-in delegate type correctly matches void ApplyDiscount(Order order, decimal percent)?
Show answer
Correct: B
Why B is correct: The method takes an Order then a decimal, in that order, and returns void — exactly what Action<Order, decimal> describes.
Why A is incorrect: That's missing the second parameter entirely.
Why C is incorrect: Func types always have a return value; this method is void.
Why D is incorrect: The parameter order is reversed — delegate type parameter order must match the method's parameter order exactly.
Reinforcement: Type parameter order on Action/Func must match the target method's parameter order precisely.
3. Why does Action reduce the need for custom delegate declarations?
Show answer
Correct: B
Why B is correct: Generics let Action<T> be declared once and reused for any type T, so the BCL doesn't need a separate named delegate type for every possible signature.
Why A is incorrect: Action has the same invocation mechanism and performance characteristics as any other delegate — there's no special speed advantage.
Why C is incorrect: A method must still match the exact required signature; Action doesn't perform any conversion of mismatched signatures.
Why D is incorrect: The delegate keyword is still very much used — Action itself is defined using it, and custom delegates remain valid and sometimes preferable.
Reinforcement: Generics, applied to delegate types, is exactly what lets Action stand in for an unlimited number of custom void-delegate declarations.
You now know when and how to use Action. Next up: its counterpart for methods that do return a value — Func.