C# Expression Trees
What Are Expression Trees
Expression trees turn code into data. Instead of executing a lambda, you can inspect its structure, transform it, or translate it to another language like SQL.
Expression trees represent code as data structures. Instead of executing immediately, the code is captured as a tree that can be analyzed, modified, or compiled.
using System.Linq.Expressions;
// Lambda expression - executes immediately
Func<int, int> doubleFunc = x => x * 2;
int result = doubleFunc(5); // 10
// Expression tree - captured as data
Expression<Func<int, int>> doubleExpr = x => x * 2;
// Can inspect: Parameter "x", Multiply operation, Constant 2
// Compile to function when ready
Func<int, int> compiled = doubleExpr.Compile();
int result2 = compiled(5); // 10
Why Expression Trees Matter
Where You Use Expression Trees
If you've written .Where(p => p.Age > 18) with Entity Framework, you've used expression trees. EF Core translates that lambda into SQL because it's an expression tree, not executable code.
Expression trees power:
- LINQ to SQL/EF Core: Translates C# queries to SQL
- Dynamic queries: Build queries at runtime
- Serialization: Send code across boundaries
- Code generation: Create optimized delegates
- Mocking frameworks: Capture method calls
// EF Core translates this to SQL
var adults = dbContext.People
.Where(p => p.Age >= 18) // Expression<Func<Person, bool>>
.OrderBy(p => p.Name) // Expression<Func<Person, string>>
.ToList();
// Generated SQL:
// SELECT * FROM People WHERE Age >= 18 ORDER BY Name
Expression Tree Structure
Anatomy of an Expression
Expression<Func<int, int, int>> addExpr = (a, b) => a + b;
// Tree structure:
// LambdaExpression
// βββ Parameters: [a, b]
// βββ Body: BinaryExpression (Add)
// βββ Left: ParameterExpression (a)
// βββ Right: ParameterExpression (b)
// Inspect the tree
var lambda = (LambdaExpression)addExpr;
Console.WriteLine($"Parameters: {string.Join(", ", lambda.Parameters)}");
Console.WriteLine($"Body: {lambda.Body}");
Console.WriteLine($"Body Type: {lambda.Body.NodeType}"); // Add
var binary = (BinaryExpression)lambda.Body;
Console.WriteLine($"Left: {binary.Left}"); // a
Console.WriteLine($"Right: {binary.Right}"); // b
Common Expression Types
| Type | Description | Example |
|---|---|---|
ConstantExpression |
Literal value | 5, "hello" |
ParameterExpression |
Parameter reference | x in x => x + 1 |
BinaryExpression |
Two operands | a + b, x > 5 |
UnaryExpression |
One operand | -x, !flag |
MemberExpression |
Property/field access | person.Name |
MethodCallExpression |
Method invocation | str.ToUpper() |
ConditionalExpression |
Ternary | x > 0 ? x : -x |
NewExpression |
Constructor call | new Point(1, 2) |
LambdaExpression |
Lambda definition | x => x * 2 |
Building Expressions Manually
Simple Expressions
// Build: x => x * 2
var param = Expression.Parameter(typeof(int), "x");
var constant = Expression.Constant(2);
var multiply = Expression.Multiply(param, constant);
var lambda = Expression.Lambda<Func<int, int>>(multiply, param);
Func<int, int> compiled = lambda.Compile();
Console.WriteLine(compiled(5)); // 10
Property Access
public class Person
{
public string Name { get; set; } = "";
public int Age { get; set; }
}
// Build: p => p.Name
var param = Expression.Parameter(typeof(Person), "p");
var property = Expression.Property(param, "Name");
var lambda = Expression.Lambda<Func<Person, string>>(property, param);
var getName = lambda.Compile();
var person = new Person { Name = "Alice" };
Console.WriteLine(getName(person)); // Alice
Method Calls
// Build: s => s.ToUpper()
var param = Expression.Parameter(typeof(string), "s");
var method = typeof(string).GetMethod("ToUpper", Type.EmptyTypes)!;
var call = Expression.Call(param, method);
var lambda = Expression.Lambda<Func<string, string>>(call, param);
var toUpper = lambda.Compile();
Console.WriteLine(toUpper("hello")); // HELLO
Complex Expressions
// Build: (a, b) => a > 0 ? a + b : a - b
var a = Expression.Parameter(typeof(int), "a");
var b = Expression.Parameter(typeof(int), "b");
var condition = Expression.GreaterThan(a, Expression.Constant(0));
var ifTrue = Expression.Add(a, b);
var ifFalse = Expression.Subtract(a, b);
var conditional = Expression.Condition(condition, ifTrue, ifFalse);
var lambda = Expression.Lambda<Func<int, int, int>>(conditional, a, b);
var func = lambda.Compile();
Console.WriteLine(func(5, 3)); // 8
Console.WriteLine(func(-5, 3)); // -8
Dynamic Query Building
Building Where Clauses
public static class QueryBuilder
{
public static Expression<Func<T, bool>> BuildPredicate<T>(
string propertyName,
object value)
{
var param = Expression.Parameter(typeof(T), "x");
var property = Expression.Property(param, propertyName);
var constant = Expression.Constant(value);
var equals = Expression.Equal(property, constant);
return Expression.Lambda<Func<T, bool>>(equals, param);
}
}
// Usage
var predicate = QueryBuilder.BuildPredicate<Person>("Name", "Alice");
var results = dbContext.People.Where(predicate).ToList();
Combining Predicates
public static class PredicateBuilder
{
public static Expression<Func<T, bool>> And<T>(
Expression<Func<T, bool>> left,
Expression<Func<T, bool>> right)
{
var param = Expression.Parameter(typeof(T), "x");
var leftBody = ReplaceParameter(left.Body, left.Parameters[0], param);
var rightBody = ReplaceParameter(right.Body, right.Parameters[0], param);
var combined = Expression.AndAlso(leftBody, rightBody);
return Expression.Lambda<Func<T, bool>>(combined, param);
}
public static Expression<Func<T, bool>> Or<T>(
Expression<Func<T, bool>> left,
Expression<Func<T, bool>> right)
{
var param = Expression.Parameter(typeof(T), "x");
var leftBody = ReplaceParameter(left.Body, left.Parameters[0], param);
var rightBody = ReplaceParameter(right.Body, right.Parameters[0], param);
var combined = Expression.OrElse(leftBody, rightBody);
return Expression.Lambda<Func<T, bool>>(combined, param);
}
private static Expression ReplaceParameter(
Expression expression,
ParameterExpression oldParam,
ParameterExpression newParam)
{
return new ParameterReplacer(oldParam, newParam).Visit(expression);
}
}
// Usage
Expression<Func<Person, bool>> isAdult = p => p.Age >= 18;
Expression<Func<Person, bool>> hasEmail = p => p.Email != null;
var combined = PredicateBuilder.And(isAdult, hasEmail);
var results = dbContext.People.Where(combined).ToList();
Dynamic OrderBy
public static IQueryable<T> OrderByProperty<T>(
this IQueryable<T> source,
string propertyName,
bool descending = false)
{
var param = Expression.Parameter(typeof(T), "x");
var property = Expression.Property(param, propertyName);
var lambda = Expression.Lambda(property, param);
string methodName = descending ? "OrderByDescending" : "OrderBy";
var method = typeof(Queryable).GetMethods()
.First(m => m.Name == methodName && m.GetParameters().Length == 2)
.MakeGenericMethod(typeof(T), property.Type);
return (IQueryable<T>)method.Invoke(null, new object[] { source, lambda })!;
}
// Usage
var sorted = dbContext.People
.OrderByProperty("Name")
.ToList();
Expression Visitors
Traversing and Modifying Trees
public class ParameterReplacer : ExpressionVisitor
{
private readonly ParameterExpression _oldParam;
private readonly ParameterExpression _newParam;
public ParameterReplacer(ParameterExpression oldParam, ParameterExpression newParam)
{
_oldParam = oldParam;
_newParam = newParam;
}
protected override Expression VisitParameter(ParameterExpression node)
{
return node == _oldParam ? _newParam : base.VisitParameter(node);
}
}
Query Analysis
public class MemberAccessVisitor : ExpressionVisitor
{
public List<string> AccessedMembers { get; } = new();
protected override Expression VisitMember(MemberExpression node)
{
AccessedMembers.Add(node.Member.Name);
return base.VisitMember(node);
}
}
// Usage - find all properties accessed in a query
Expression<Func<Person, bool>> expr = p => p.Age > 18 && p.Name.StartsWith("A");
var visitor = new MemberAccessVisitor();
visitor.Visit(expr);
// AccessedMembers: ["Age", "Name"]
Expression Transformation
// Convert property access to dictionary lookup
public class PropertyToDictVisitor : ExpressionVisitor
{
private readonly ParameterExpression _dictParam;
public PropertyToDictVisitor(ParameterExpression dictParam)
{
_dictParam = dictParam;
}
protected override Expression VisitMember(MemberExpression node)
{
if (node.Expression is ParameterExpression)
{
// Convert p.Name to dict["Name"]
var key = Expression.Constant(node.Member.Name);
var indexer = typeof(Dictionary<string, object>)
.GetProperty("Item")!;
return Expression.MakeIndex(_dictParam, indexer, new[] { key });
}
return base.VisitMember(node);
}
}
Compiled Expressions for Performance
Caching Compiled Delegates
Compile() Is Expensive
Compiling an expression tree to a delegate is slow, much slower than reflection. Always cache the compiled result when the same expression will be used repeatedly.
public static class PropertyAccessor<T>
{
private static readonly ConcurrentDictionary<string, Func<T, object?>> Getters = new();
public static Func<T, object?> GetGetter(string propertyName)
{
return Getters.GetOrAdd(propertyName, CreateGetter);
}
private static Func<T, object?> CreateGetter(string propertyName)
{
var param = Expression.Parameter(typeof(T), "x");
var property = Expression.Property(param, propertyName);
var converted = Expression.Convert(property, typeof(object));
var lambda = Expression.Lambda<Func<T, object?>>(converted, param);
return lambda.Compile();
}
}
// Usage - much faster than reflection after first call
var getter = PropertyAccessor<Person>.GetGetter("Name");
var name = getter(person);
Fast Property Setters
public static class PropertySetter<T>
{
private static readonly ConcurrentDictionary<string, Action<T, object?>> Setters = new();
public static Action<T, object?> GetSetter(string propertyName)
{
return Setters.GetOrAdd(propertyName, CreateSetter);
}
private static Action<T, object?> CreateSetter(string propertyName)
{
var param = Expression.Parameter(typeof(T), "x");
var valueParam = Expression.Parameter(typeof(object), "value");
var property = Expression.Property(param, propertyName);
var converted = Expression.Convert(valueParam, property.Type);
var assign = Expression.Assign(property, converted);
var lambda = Expression.Lambda<Action<T, object?>>(assign, param, valueParam);
return lambda.Compile();
}
}
Practical Examples
Generic Mapper
public static class Mapper<TSource, TDest> where TDest : new()
{
private static readonly Func<TSource, TDest> MapFunc = CreateMapper();
private static Func<TSource, TDest> CreateMapper()
{
var sourceParam = Expression.Parameter(typeof(TSource), "src");
var bindings = new List<MemberBinding>();
foreach (var destProp in typeof(TDest).GetProperties())
{
var sourceProp = typeof(TSource).GetProperty(destProp.Name);
if (sourceProp != null && sourceProp.PropertyType == destProp.PropertyType)
{
var sourceAccess = Expression.Property(sourceParam, sourceProp);
bindings.Add(Expression.Bind(destProp, sourceAccess));
}
}
var newExpr = Expression.New(typeof(TDest));
var memberInit = Expression.MemberInit(newExpr, bindings);
var lambda = Expression.Lambda<Func<TSource, TDest>>(memberInit, sourceParam);
return lambda.Compile();
}
public static TDest Map(TSource source) => MapFunc(source);
}
// Usage
var dto = Mapper<Person, PersonDto>.Map(person);
Specification Pattern
public abstract class Specification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public bool IsSatisfiedBy(T entity)
{
return ToExpression().Compile()(entity);
}
public Specification<T> And(Specification<T> other)
{
return new AndSpecification<T>(this, other);
}
public Specification<T> Or(Specification<T> other)
{
return new OrSpecification<T>(this, other);
}
}
public class AndSpecification<T> : Specification<T>
{
private readonly Specification<T> _left;
private readonly Specification<T> _right;
public AndSpecification(Specification<T> left, Specification<T> right)
{
_left = left;
_right = right;
}
public override Expression<Func<T, bool>> ToExpression()
{
return PredicateBuilder.And(_left.ToExpression(), _right.ToExpression());
}
}
// Concrete specifications
public class AdultSpecification : Specification<Person>
{
public override Expression<Func<T, bool>> ToExpression()
=> p => p.Age >= 18;
}
// Usage
var spec = new AdultSpecification()
.And(new HasEmailSpecification());
var results = dbContext.People.Where(spec.ToExpression()).ToList();
Limitations and Considerations
What Canβt Be in Expression Trees
// These compile as Func, not Expression:
// Dynamic
Expression<Func<dynamic, int>> bad1 = d => d.Value; // Error
// Out/ref parameters
Expression<Action<out int>> bad2 = (out int x) => x = 1; // Error
// Named/optional parameters
Expression<Func<int>> bad3 = () => Method(name: "test"); // Error in some cases
// Async lambdas
Expression<Func<Task<int>>> bad4 = async () => await Task.FromResult(1); // Error
// Statements (only expressions allowed)
Expression<Action> bad5 = () => { var x = 1; }; // Error
Performance Notes
// Compile() is expensive - cache results
// BAD: Compiles every call
public bool Check(Expression<Func<Person, bool>> expr, Person p)
{
return expr.Compile()(p); // Slow!
}
// GOOD: Cache compiled delegate
private readonly ConcurrentDictionary<Expression, Delegate> _cache = new();
public bool CheckCached(Expression<Func<Person, bool>> expr, Person p)
{
var compiled = (Func<Person, bool>)_cache.GetOrAdd(
expr,
e => ((Expression<Func<Person, bool>>)e).Compile());
return compiled(p);
}
Version History
| Feature | Version | Significance |
|---|---|---|
| Expression Trees | C# 3.0 | LINQ foundation |
| Expression Visitor | .NET 4.0 | Tree traversal/modification |
| Improved debugging | .NET 4.0 | DebugView property |
| Block expressions | .NET 4.0 | Statement-like expressions |
| Index expressions | .NET 4.0 | Indexer access |
Key Takeaways
Code as data: Expression trees let you inspect and manipulate code structure at runtime.
LINQ translation: Theyβre how EF Core and other providers translate C# to SQL.
Cache compiled delegates: Compile() is expensive. Cache results for repeated use.
Use ExpressionVisitor: The standard way to traverse and transform expression trees.
Prefer lambdas when possible: Manual tree building is verbose. Use Expression<Func<>> from lambdas when the expression is known at compile time.
Limitations exist: Async, out parameters, statements, and some language features arenβt supported.
Found this guide helpful? Share it with your team:
Share on LinkedIn