Expression Trees and Dynamic
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Two C# features let a program work with code, or with members, that the compiler did not resolve statically:
expression trees represent code as an inspectable data structure, and dynamic defers member lookup to run
time. They solve different problems and are worth keeping apart: expression trees are about translating
code, dynamic is about not knowing the type yet.
Expression Trees
A Delegate versus an Expression
The same lambda means two different things depending on what it is assigned to:
public static class DelegateVersusExpression
{
public static void Run()
{
// A delegate: compiled IL. You can call it; you cannot look inside it.
Func<int, bool> asDelegate = n => n > 5;
Console.WriteLine(asDelegate(7)); // True
// An expression tree: a data structure describing the same code.
Expression<Func<int, bool>> asTree = n => n > 5;
Console.WriteLine(asTree); // n => (n > 5)
Console.WriteLine(asTree.NodeType); // Lambda
Console.WriteLine(asTree.Body.NodeType); // GreaterThan
Console.WriteLine(asTree.Parameters[0].Name); // n
var comparison = (BinaryExpression)asTree.Body;
Console.WriteLine(comparison.Left); // n
Console.WriteLine(comparison.Right); // 5
}
}
Expression<TDelegate> derives from LambdaExpression, which derives from Expression. The compiler builds
the tree at the call site, so assigning a lambda to an Expression<…> costs a small allocation but no
compilation.
The Node Types
Every node is an Expression subclass with a NodeType from the ExpressionType enum. The ones you meet most:
| Node class | Represents | Example |
|---|---|---|
|
A parameter or local |
|
|
A literal or captured value |
|
|
Two-operand operator |
|
|
One-operand operator, conversions |
|
|
Field or property access |
|
|
A method call |
|
|
Object construction |
|
|
The conditional operator |
|
|
The lambda itself |
|
Building a Tree by Hand
Anything the compiler can build, you can build with the Expression factory methods:
public static class BuildingTrees
{
public static void Run()
{
// Build `(n, factor) => n * factor + 1` from scratch.
ParameterExpression n = Expression.Parameter(typeof(int), "n");
ParameterExpression factor = Expression.Parameter(typeof(int), "factor");
BinaryExpression product = Expression.Multiply(n, factor);
BinaryExpression body = Expression.Add(product, Expression.Constant(1));
Expression<Func<int, int, int>> lambda =
Expression.Lambda<Func<int, int, int>>(body, n, factor);
Console.WriteLine(lambda); // (n, factor) => ((n * factor) + 1)
// Compile turns the tree into a real delegate, emitting IL at run time.
Func<int, int, int> compiled = lambda.Compile();
Console.WriteLine(compiled(5, 3)); // 16
}
public static void CallAMethod()
{
// Build `s => s.StartsWith("a")`.
ParameterExpression s = Expression.Parameter(typeof(string), "s");
MethodInfo startsWith = typeof(string).GetMethod(nameof(string.StartsWith), [typeof(string)])!;
MethodCallExpression call = Expression.Call(s, startsWith, Expression.Constant("a"));
var predicate = Expression.Lambda<Func<string, bool>>(call, s).Compile();
Console.WriteLine(predicate("apple")); // True
Console.WriteLine(predicate("pear")); // False
}
}
Compile() is not free — it runs the expression compiler and emits a dynamic method. Compile once and cache
the delegate; never compile inside a loop. On Native AOT, Compile() falls back to an interpreter (or is
unavailable), which is one reason expression trees are a poor fit for trimmed deployments.
A Practical Use: Reading a Member Name
The most common everyday use of an expression tree is not translation at all — it is extracting metadata that a string would otherwise have to carry:
public sealed class Person
{
public required string FirstName { get; init; }
public int Age { get; init; }
}
public static class MemberNames
{
public static string NameOfMember<T, TMember>(Expression<Func<T, TMember>> selector) => selector.Body switch
{
MemberExpression member => member.Member.Name,
// Value-typed members get wrapped in a Convert to object -- unwrap it.
UnaryExpression { Operand: MemberExpression member } => member.Member.Name,
_ => throw new ArgumentException("expected a member access", nameof(selector)),
};
public static void Run()
{
Console.WriteLine(NameOfMember<Person, string>(p => p.FirstName)); // FirstName
Console.WriteLine(NameOfMember<Person, int>(p => p.Age)); // Age
}
}
For this particular job, prefer nameof(Person.FirstName) — it is free and checked at compile time. The
expression-tree form earns its keep only when the member must be chosen by the caller of a generic API, as
validation and ORM libraries do.
Visiting and Rewriting: ExpressionVisitor
ExpressionVisitor walks a tree and, by default, rebuilds it unchanged. Override the node types you care about
to inspect or rewrite:
// Collects every constant in a tree -- the "inspect" half.
public sealed class ConstantCollector : ExpressionVisitor
{
public List<object?> Constants { get; } = [];
protected override Expression VisitConstant(ConstantExpression node)
{
Constants.Add(node.Value);
return base.VisitConstant(node);
}
}
// Replaces every `>` with `>=` -- the "rewrite" half.
public sealed class LoosenComparisons : ExpressionVisitor
{
protected override Expression VisitBinary(BinaryExpression node) =>
node.NodeType == ExpressionType.GreaterThan
? Expression.GreaterThanOrEqual(Visit(node.Left), Visit(node.Right))
: base.VisitBinary(node);
}
public static class Visiting
{
public static void Run()
{
Expression<Func<int, bool>> original = n => n > 5;
var collector = new ConstantCollector();
collector.Visit(original);
Console.WriteLine(string.Join(",", collector.Constants)); // 5
var rewritten = (Expression<Func<int, bool>>)new LoosenComparisons().Visit(original);
Console.WriteLine(rewritten); // n => (n >= 5)
Console.WriteLine(rewritten.Compile()(5)); // True -- was False before
}
}
A visitor is also how you translate a tree into something else entirely — SQL, a URL query string, a different object model:
// A deliberately tiny translator: turns a predicate into a SQL WHERE fragment.
public sealed class SqlWhereTranslator : ExpressionVisitor
{
private readonly StringBuilder _sql = new();
public string Translate(Expression expression)
{
_sql.Clear();
Visit(expression);
return _sql.ToString();
}
protected override Expression VisitLambda<T>(Expression<T> node)
{
Visit(node.Body);
return node;
}
protected override Expression VisitBinary(BinaryExpression node)
{
_sql.Append('(');
Visit(node.Left);
_sql.Append(node.NodeType switch
{
ExpressionType.Equal => " = ",
ExpressionType.NotEqual => " <> ",
ExpressionType.GreaterThan => " > ",
ExpressionType.LessThan => " < ",
ExpressionType.AndAlso => " AND ",
ExpressionType.OrElse => " OR ",
_ => throw new NotSupportedException($"operator {node.NodeType} is not translatable"),
});
Visit(node.Right);
_sql.Append(')');
return node;
}
protected override Expression VisitMember(MemberExpression node)
{
_sql.Append(node.Member.Name);
return node;
}
protected override Expression VisitConstant(ConstantExpression node)
{
_sql.Append(node.Value is string text ? $"'{text}'" : node.Value);
return node;
}
}
public static class Translating
{
public static void Run()
{
Expression<Func<Person, bool>> predicate = p => p.Age > 18 && p.FirstName == "Ada";
Console.WriteLine(new SqlWhereTranslator().Translate(predicate));
// ((Age > 18) AND (FirstName = 'Ada'))
}
}
The NotSupportedException in the default arm is not an accident — it is exactly the shape of the error EF
Core reports when a query uses a method it cannot translate.
How IQueryable<T> Providers Use Trees
IQueryable<T> is IEnumerable<T> plus two properties: an Expression describing the query so far, and a
Provider that knows how to execute it.
public static class QueryableShape
{
public static void Inspect(IQueryable<Person> people)
{
// Each operator returns a new IQueryable whose Expression wraps the previous one.
IQueryable<string> query = people
.Where(p => p.Age >= 18)
.OrderBy(p => p.FirstName)
.Select(p => p.FirstName);
// Nothing has executed: the tree just grew.
Console.WriteLine(query.Expression.NodeType); // Call
Console.WriteLine(query.ElementType.Name); // String
// Execution happens at the terminal operator -- ToList, First, Count, foreach.
// The provider walks the tree, emits its own dialect and runs it.
}
// In-memory sequences become queryable with AsQueryable, which supplies the
// EnumerableQuery provider -- it simply compiles the tree and runs it locally.
public static void Run()
{
Person[] people = [new Person { FirstName = "Ada", Age = 36 }];
Inspect(people.AsQueryable());
Console.WriteLine(people.AsQueryable().Where(p => p.Age > 18).Count()); // 1
}
}
This is why the choice between IEnumerable<T> and IQueryable<T> changes a query’s meaning so completely — see LINQ.
Restrictions on Expression-Tree Lambdas
A lambda converted to Expression<…> must be a single expression. The compiler rejects:
-
statement bodies (
{ … }), and therefore loops,try, and local declarations; -
assignments and compound assignments,
++/--; -
await,ref/outarguments, andunsafecode; -
dynamicoperations, and lambdas containing them; -
tuple deconstruction,
ispatterns andswitchexpressions (a longstanding gap); -
collection expressions and interpolated-string handler conversions;
-
optional-argument and
paramsexpansion is allowed, but the tree records the expanded call.
public static class TreeRestrictions
{
public static void Run()
{
// Fine -- a single expression.
Expression<Func<int, int>> ok = n => n * 2 + 1;
// Fine -- a conditional expression stands in for an if/else.
Expression<Func<int, string>> conditional = n => n > 0 ? "positive" : "not positive";
// These would NOT compile as expression trees:
// Expression<Func<int, int>> statements = n => { return n * 2; }; // statement body
// Expression<Action<int>> assign = n => { n += 1; }; // assignment
// Expression<Func<int, Task>> waiting = async n => await Task.Delay(n); // await
Console.WriteLine($"{ok.Compile()(3)} {conditional.Compile()(3)}");
}
}
Expression.Block, Expression.Loop and friends can express those constructs — the restriction is on what
the compiler will build for you from a lambda, not on what the tree model supports.
dynamic and the DLR
What dynamic Does
dynamic is a static type that tells the compiler to postpone every operation on the value — member lookup,
overload resolution, operator selection — until run time, where the Dynamic Language Runtime performs it
against the object’s actual type.
public static class DynamicBasics
{
public static void Run()
{
dynamic value = "hello";
Console.WriteLine(value.Length); // 5 -- resolved at run time
Console.WriteLine(value.ToUpperInvariant()); // HELLO
value = 42; // the same variable, a different type
Console.WriteLine(value + 1); // 43 -- int addition chosen at run time
// A member that does not exist compiles happily and fails at run time.
try
{
Console.WriteLine(value.NoSuchMember());
}
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ex)
{
Console.WriteLine($"binder failed: {ex.Message.Split('\n')[0]}");
}
}
}
dynamic is not object: object requires a cast before you can do anything, dynamic requires none and
checks nothing. It is also not reflection — the DLR caches call sites, so a repeated dynamic call is far
faster than a repeated MethodInfo.Invoke, though still far slower than a static call.
public static class DynamicVersusObject
{
public static void Run()
{
object asObject = "hello";
// Console.WriteLine(asObject.Length); // compile error: object has no Length
Console.WriteLine(((string)asObject).Length);
dynamic asDynamic = "hello";
Console.WriteLine(asDynamic.Length); // compiles, and works
// Dynamic is contagious: an expression with a dynamic operand is itself dynamic.
dynamic result = asDynamic.Substring(0, 2);
string narrowed = result; // implicit conversion back, checked at run time
Console.WriteLine(narrowed); // he
}
}
ExpandoObject
ExpandoObject is a bag of members you add at run time — useful for loosely structured data:
public static class Expando
{
public static void Run()
{
dynamic config = new ExpandoObject();
config.Host = "localhost";
config.Port = 8080;
config.Describe = (Func<string>)(() => $"{config.Host}:{config.Port}");
Console.WriteLine(config.Describe()); // localhost:8080
// It is also a dictionary, which is how you enumerate or probe it.
var asDictionary = (IDictionary<string, object?>)config;
Console.WriteLine(asDictionary.ContainsKey("Host")); // True
Console.WriteLine(string.Join(",", asDictionary.Keys)); // Host,Port,Describe
asDictionary.Remove("Port");
Console.WriteLine(asDictionary.ContainsKey("Port")); // False
}
}
DynamicObject and IDynamicMetaObjectProvider
Deriving from DynamicObject lets a type decide for itself what a member access means — the mechanism behind
dynamic ORMs, configuration wrappers and scripting bridges:
// A case-insensitive settings bag that returns null for anything it does not know.
public sealed class Settings : DynamicObject
{
private readonly Dictionary<string, object?> _values =
new(StringComparer.OrdinalIgnoreCase);
public override bool TryGetMember(GetMemberBinder binder, out object? result)
{
_values.TryGetValue(binder.Name, out result);
return true; // true == "handled"; false raises RuntimeBinderException
}
public override bool TrySetMember(SetMemberBinder binder, object? value)
{
_values[binder.Name] = value;
return true;
}
public override bool TryInvokeMember(InvokeMemberBinder binder, object?[]? args, out object? result)
{
result = $"called {binder.Name} with {args?.Length ?? 0} argument(s)";
return true;
}
public override IEnumerable<string> GetDynamicMemberNames() => _values.Keys;
}
public static class DynamicObjects
{
public static void Run()
{
dynamic settings = new Settings();
settings.Timeout = 30;
Console.WriteLine(settings.TIMEOUT); // 30 -- case-insensitive by construction
Console.WriteLine(settings.Unknown is null); // True -- no exception
Console.WriteLine(settings.Refresh(1, 2)); // called Refresh with 2 argument(s)
}
}
IDynamicMetaObjectProvider is the underlying interface; implement it directly only when you need control over
the DLR’s call-site caching, which is rare.
COM Interop
dynamic was introduced largely to make COM automation bearable. Against an Office object model, it removes
the casts that late-bound COM otherwise demands:
public static class ComInterop
{
// Sketch only: requires Windows and the relevant COM server to be registered.
public static void Sketch(dynamic application)
{
application.Visible = true;
dynamic workbook = application.Workbooks.Add();
dynamic sheet = workbook.Worksheets[1];
sheet.Cells[1, 1] = "Total"; // no casts from object, no Missing.Value arguments
sheet.Cells[1, 2] = 42;
workbook.Close(SaveChanges: false); // named arguments work against COM too
}
}
See Native Interop for the rest of the COM story.
When to Use dynamic — and When Not To
Reasonable uses:
-
COM automation, where the API is late-bound by design;
-
consuming genuinely schemaless data (a JSON document whose shape varies) where a DOM would be noisier;
-
bridging to a dynamic language hosted in-process (IronPython and similar).
Poor uses:
-
avoiding the work of declaring an interface or a generic type parameter;
-
duck typing across your own types — an interface says the same thing and is checked;
-
anything on a hot path (call-site caching helps, but a static call is still an order of magnitude faster);
-
anything destined for Native AOT or trimming, where the DLR’s reflection is a liability.
The cost is that every mistake moves from compile time to run time: no IntelliSense, no refactoring support, no
compiler error — just a RuntimeBinderException in production. Reach for dynamic only when static typing is
genuinely unable to express the problem.
See Also
-
LINQ —
IQueryable<T>and provider translation. -
Delegates, Lambdas and Events — the compiled counterpart of an expression tree.
-
Attributes and Reflection — the other run-time metadata API, and source generators as the static alternative.
-
Native Interop — COM, where
dynamicearns its place. -
ASP.NET Reference — EF Core, the expression-tree consumer most C# developers actually meet.