DEV Community

Cover image for Dapper.TypedParameters: Making SQL Server Parameter Types Explicit in Dapper
Rodrigo de Oliveira
Rodrigo de Oliveira

Posted on

Dapper.TypedParameters: Making SQL Server Parameter Types Explicit in Dapper

One of the things I like most about Dapper is that it stays out of the way.

You write the SQL, pass the parameters, execute the query, and get your objects back. There is very little ceremony between your application and the database, which is exactly why Dapper works so well in many backend systems.

But simplicity sometimes hides details.

In this case, the detail that caught my attention was parameter metadata.

A .NET string is just a string to our application. SQL Server, however, does not have a single generic “string” type. It has varchar, nvarchar, char, nchar, different sizes, and several other pieces of metadata that can be relevant when a parameter is materialized and sent to the database.

That small gap between the CLR type we see in code and the SQL type that actually reaches SQL Server is what eventually led me to build Dapper.TypedParameters.

The library has now reached its first stable version, 1.0.0, and its goal is intentionally narrow:

Let developers explicitly describe SQL Server parameter metadata when the database contract is already known.

The idea started with a very ordinary Dapper query

Consider a typical Dapper query:

var customer = await connection.QuerySingleOrDefaultAsync<Customer>(
    """
    SELECT Id, Document, Name
    FROM dbo.Customers
    WHERE Document = @Document;
    """,
    new
    {
        Document = document
    });
Enter fullscreen mode Exit fullscreen mode

There is nothing wrong with this code.

In the application, document is a .NET string, and Dapper delegates parameter materialization to the underlying ADO.NET provider.

Now imagine the database schema contains:

Document varchar(11)
Enter fullscreen mode Exit fullscreen mode

At the call site, we know we have a string.

But the database contract is more specific:

varchar(11)
Enter fullscreen mode Exit fullscreen mode

That distinction may or may not matter for a given query, but the information exists.

The question I started asking was simple:

If the application already knows the database contract, should it be possible to express that contract explicitly?

With Dapper.TypedParameters, the same query becomes:

var customer = await connection.QuerySingleOrDefaultAsync<Customer>(
    """
    SELECT Id, Document, Name
    FROM dbo.Customers
    WHERE Document = @Document;
    """,
    new
    {
        Document = SqlParam.VarChar(document, 11)
    });
Enter fullscreen mode Exit fullscreen mode

Now the intent is visible.

.NET string
    ↓
explicit SQL metadata
    ↓
varchar(11)
Enter fullscreen mode Exit fullscreen mode

That is essentially the whole idea behind the project.

This is not about saying varchar is better than nvarchar

This is an important distinction.

The library does not assume that varchar is better than nvarchar, or that developers should replace inferred parameters everywhere.

If your column is:

Name nvarchar(150)
Enter fullscreen mode Exit fullscreen mode

then a corresponding parameter can be written as:

Name = SqlParam.NVarChar(name, 150)
Enter fullscreen mode Exit fullscreen mode

The point is not to prefer one SQL type over another.

The point is to make the expected database type explicit when you already know what it is.

That keeps the decision where I think it belongs: in the hands of the developer who understands the schema and the query.

The library does not inspect the database and decide for you.

What about performance?

This is where I think technical discussions can become misleading very quickly.

A mismatch between parameter metadata and column metadata can cause SQL Server to perform implicit conversions. Depending on the types involved, type precedence, collation, indexes, query shape, and the resulting execution plan, those conversions may affect query behavior or performance.

But that does not mean:

“Dapper parameter inference is slow.”

And it certainly does not mean:

“Using explicitly typed parameters automatically makes queries faster.”

Neither statement is accurate.

Dapper's normal parameter inference is perfectly appropriate for many applications.

Dapper.TypedParameters simply gives you more control when that control matters.

If performance is important, the usual rules still apply: inspect the execution plan, measure the workload, understand the query, and avoid optimizing based only on assumptions.

I deliberately avoided turning this library into something that promises performance improvements it cannot universally guarantee.

Its value is explicitness and control, not magic optimization.

The API grew beyond strings, but the principle stayed the same

Strings were the easiest starting point, but the same concept applies naturally to other SQL Server types.

For example, a decimal parameter can make precision and scale visible:

Amount = SqlParam.Decimal(
    amount,
    precision: 18,
    scale: 2)
Enter fullscreen mode Exit fullscreen mode

That makes the intended contract clear:

decimal(18, 2)
Enter fullscreen mode Exit fullscreen mode

Temporal parameters work the same way:

CreatedAt = SqlParam.DateTime2(createdAt, scale: 7)
Enter fullscreen mode Exit fullscreen mode

And identifiers:

CustomerId = SqlParam.UniqueIdentifier(customerId)
Enter fullscreen mode Exit fullscreen mode

The 1.0.0 release supports the most common families I wanted to cover before stabilizing the API: strings, numeric values, binary values, identifiers, and temporal values.

It also supports output and input/output parameters, as well as Table-Valued Parameters.

The important part is that all of them follow the same design philosophy: the API should expose SQL Server intent without changing the way developers normally work with Dapper.

Output parameters without hiding the execution lifecycle

Stored procedures are still common in many enterprise systems, so output parameters were an important part of the stable release.

A parameter can be declared as output:

var result = SqlParam.Int(null).AsOutput();

await connection.ExecuteAsync(
    "dbo.CalculateSomething",
    new
    {
        Result = result
    },
    commandType: CommandType.StoredProcedure);

var value = result.GetValue<int>();
Enter fullscreen mode Exit fullscreen mode

There is also support for AsInputOutput() and direct access through OutputValue.

One detail I wanted to keep explicit is the lifecycle.

The output value only exists after the command has been executed.

That sounds obvious, but abstractions sometimes try so hard to make things convenient that they end up hiding important behavior.

I would rather keep the model understandable.

Create the parameter, execute the command, then read the value.

Table-Valued Parameters follow the same philosophy

TVPs are another good example of knowing where a library should stop.

Dapper.TypedParameters supports them using an explicit SQL Server type name and a caller-provided DataTable.

Conceptually:

var ids = SqlParam.TableValued(
    "dbo.CustomerIds",
    table);
Enter fullscreen mode Exit fullscreen mode

The resulting SQL parameter is configured as SqlDbType.Structured.

What the library does not do is automatically map arbitrary POCO collections to SQL Server user-defined table types.

It also does not create those types in the database.

The caller remains responsible for making sure the DataTable shape matches the SQL Server type.

Could the library do more?

Of course.

But then it would slowly stop being a typed-parameter library and start becoming a mapping framework.

That is not the direction I want for it.

Knowing what not to build matters too

A large part of designing the first stable version was deciding what should stay outside the project.

Dapper.TypedParameters does not inspect schemas.

It does not rewrite SQL.

It does not analyze execution plans.

It does not search for CONVERT_IMPLICIT.

It does not automatically select SQL types.

It does not query SQL Server to discover what a column looks like.

If you write:

SqlParam.VarChar(document, 11)
Enter fullscreen mode Exit fullscreen mode

the library assumes you know why the parameter should be varchar(11).

That trust is intentional.

Adding automatic discovery would introduce database access, metadata caching, permissions, connection lifecycle concerns, and many other responsibilities that have nothing to do with the original problem.

Sometimes keeping a library small is harder than adding another feature.

Reaching 1.0 involved much more than writing the API

One of the most useful reminders from this project was that there is a big difference between:

“The code works.”

and:

“Other people can safely depend on this package.”

Once a library is on NuGet, public API decisions become contracts.

Someone may compile an application against a property or method you casually added six months ago and expect it to keep working across future 1.x releases.

That changed how I approached the path to 1.0.0.

The library went through preview and release-candidate versions before the public API was frozen.

The package targets .NET 8 and .NET 10, using Microsoft.Data.SqlClient.

The repository includes unit tests and real SQL Server integration tests using Docker and Testcontainers. It also validates package contents, package consumption, public API compatibility, SourceLink, package validation, and SonarQube Cloud Quality Gates.

NuGet publication uses Trusted Publishing through GitHub Actions rather than storing a long-lived NuGet API key.

That may sound like a lot of infrastructure for a small library.

But package size and maintenance responsibility are two very different things.

Installing it

The package is available on NuGet as:

TypedParameters.Dapper.SqlServer
Enter fullscreen mode Exit fullscreen mode

Installation is straightforward:

dotnet add package TypedParameters.Dapper.SqlServer
Enter fullscreen mode Exit fullscreen mode

Then import the namespace:

using Dapper.TypedParameters.SqlServer;
Enter fullscreen mode Exit fullscreen mode

And use typed parameters inside the same anonymous objects you already use with Dapper:

new
{
    Document = SqlParam.VarChar(document, 11),
    Name = SqlParam.NVarChar(name, 150),
    Amount = SqlParam.Decimal(amount, 18, 2)
}
Enter fullscreen mode Exit fullscreen mode

There is no new repository abstraction, query builder, or database context to adopt.

That was important to me.

A small library with a deliberately small job

Dapper.TypedParameters was not created because I think Dapper's parameter system is broken.

Quite the opposite: I like Dapper because of how little it gets between the application and SQL.

The library exists because sometimes explicitness is useful.

Instead of:

Document = document
Enter fullscreen mode Exit fullscreen mode

you can choose to write:

Document = SqlParam.VarChar(document, 11)
Enter fullscreen mode Exit fullscreen mode

Those extra characters capture a decision that would otherwise remain implicit.

That is the core idea behind the project:

less inference when you want control, without stopping Dapper from being Dapper.

The source code is available on GitHub:

github.com/rodri-oliveira-dev/Dapper.TypedParameters

And the stable package is available on NuGet:

TypedParameters.Dapper.SqlServer

Now that 1.0.0 is out, the part I am most interested in begins: seeing how the API behaves in real applications outside the scenarios I used while designing it.

If you work with Dapper and SQL Server, feedback is very welcome — especially around API ergonomics, missing parameter scenarios, and cases where making SQL metadata explicit has proved useful.

Top comments (0)