DEV Community

Cover image for C# dynamic is a trap door: stop the leaks before they spread (Must read for Dapper users)
Dmitry Dorogoy
Dmitry Dorogoy

Posted on

C# dynamic is a trap door: stop the leaks before they spread (Must read for Dapper users)

In C#, dynamic can be handy at boundaries. The problem is that it can silently leak into places where you expect static typing, and your method signatures can still look perfectly safe.

I ran into these leaks in real projects, so I built a small Roslyn analyzer to catch them early. I am the author of DynamicLeakAnalyzer (NuGet: DimonSmart.DynamicLeakAnalyzer), and this post explains the problem it targets and how to fix leaks at the boundary.

What dynamic really means

In C#, dynamic is a static type, but it bypasses compile-time type checking for the expression. Member access, overload resolution, operators, and conversions are bound at runtime.

At runtime, values still flow as object. The difference is that the compiler emits runtime binding (DLR call sites). Those call sites have caching, so repeated calls can get faster after the first bind.

How "dynamic contagion" happens

Two patterns make leaks hard to spot during review:

  • Invisible runtime work: member access and conversions happen at runtime.
  • Deceptive signatures: a method can return int and still perform dynamic conversions inside.
  • var can silently become dynamic: when the right side is dynamic, the inferred type becomes dynamic.

Here is a small example that shows both problems. The method looks fully static and returns int, yet dynamic enters through the parameter.

class Program
{
    static int GetX(int i) => i;

    static void Main()
    {
        dynamic prm = 123;

        int a = GetX(prm); // DSM001: implicit dynamic conversion at runtime
        var b = GetX(prm); // DSM002: b becomes dynamic because the invocation is dynamic
    }
}
Enter fullscreen mode Exit fullscreen mode

In real code, prm is often not a local variable. It can be an object passed into the method, and dynamic can hide in a field or property, for example prm.Payload.Id.

The Dapper trap

Dapper makes it easy to introduce dynamic without noticing it. Calling QueryFirst without <T> returns a dynamic row object (usually DapperRow), so property access becomes dynamic.

using Dapper;
using System.Data;

public static class Repo
{
    public static int GetActiveUserId(IDbConnection cn)
    {
        // QueryFirst() without <T> returns a dynamic row (DapperRow).
        var row = cn.QueryFirst("select Id from Users where IsActive = 1");

        // row.Id is dynamic, the conversion to int happens at runtime.
        return row.Id; // DSM001
    }
}
Enter fullscreen mode Exit fullscreen mode

This kind of code often passes reviews because the signature says int. The dynamic binding is hidden in the middle.

Safer alternatives in Dapper

Prefer typed APIs at the boundary:

int id = cn.QuerySingle<int>("select Id from Users where IsActive = 1");
Enter fullscreen mode Exit fullscreen mode

Or map to a small DTO:

public sealed record UserId(int Id);

int id = cn.QuerySingle<UserId>("select Id from Users where IsActive = 1").Id;
Enter fullscreen mode Exit fullscreen mode

If you really must use a dynamic row, kill it immediately:

var row = cn.QueryFirst("select Id from Users where IsActive = 1");
int id = (int)row.Id;
Enter fullscreen mode Exit fullscreen mode

The goal is not "never use dynamic". The goal is "stop the leak at the boundary".

The solution: DynamicLeakAnalyzer

DynamicLeakAnalyzer is a Roslyn analyzer that makes these leaks loud before they spread.

It reports two rules:

  • DSM001 (Implicit dynamic conversion): a dynamic expression is used where a static type is expected (return, assignment, argument, etc.). The code compiles, but the conversion happens at runtime.
  • DSM002 (var inferred as dynamic): var captures a dynamic result and becomes dynamic.

Install and enforce

Add the analyzer:

dotnet add package DimonSmart.DynamicLeakAnalyzer
Enter fullscreen mode Exit fullscreen mode

Make warnings hurt using .editorconfig:

root = true

[*.cs]
dotnet_diagnostic.DSM001.severity = error
dotnet_diagnostic.DSM002.severity = error
Enter fullscreen mode Exit fullscreen mode

Where dynamic is fine, and where it is not

Good boundary examples:

  • COM interop
  • JSON adapters and glue code
  • database adapters (including dynamic Dapper rows)

Avoid dynamic in:

  • core domain logic
  • hot loops
  • libraries meant for other developers

Next steps

  • Run the analyzer on a real codebase and see where dynamic leaks already exist.
  • If you use Dapper, search for QueryFirst( and non-generic Query( calls that return dynamic rows.
  • If you have false positives or missed cases, open an issue with a minimal repro.

Top comments (0)