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

ParameterExpression

A parameter or local

n

ConstantExpression

A literal or captured value

5

BinaryExpression

Two-operand operator

n > 5, a + b

UnaryExpression

One-operand operator, conversions

-n, (long)n

MemberExpression

Field or property access

p.Name

MethodCallExpression

A method call

s.StartsWith("a")

NewExpression

Object construction

new Point(1, 2)

ConditionalExpression

The conditional operator

a ? b : c

LambdaExpression

The lambda itself

n ⇒ n > 5

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/out arguments, and unsafe code;

  • dynamic operations, and lambdas containing them;

  • tuple deconstruction, is patterns and switch expressions (a longstanding gap);

  • collection expressions and interpolated-string handler conversions;

  • optional-argument and params expansion 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