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 that 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
intand still perform dynamic conversions inside. -
varcan silently becomedynamic: 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
}
}
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
}
}
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");
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;
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;
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
dynamicexpression is used where a static type is expected (return, assignment, argument, etc.). The code compiles, but the conversion happens at runtime. -
DSM002 (
varinferred asdynamic):varcaptures a dynamic result and becomes dynamic.
Install and enforce
Add the analyzer:
dotnet add package DimonSmart.DynamicLeakAnalyzer
Make warnings hurt using .editorconfig:
root = true
[*.cs]
dotnet_diagnostic.DSM001.severity = error
dotnet_diagnostic.DSM002.severity = error
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-genericQuery(calls that return dynamic rows. - If you have false positives or missed cases, open an issue with a minimal repro.