Interpolated SQL parameterization for .NET — injection-safe by construction, with per-provider type checking where the provider supports it.
Built on C# interpolated string handlers. The compiler binds every {parameter} to a typed parameter at build time, with no string concatenation.
Requires .NET 10. All packages target net10.0.
For F# users: see fsharp/.
var userId = 42;
var threshold = TimeSpan.FromHours(36);
// Postgres — TimeSpan maps to interval, any duration works
PostgresSql statement = $"SELECT * FROM jobs WHERE user_id = {userId} AND elapsed > {threshold}";
// SQL Server — named parameters automatically
SqlServerSql statement = $"SELECT * FROM jobs WHERE user_id = {userId} AND elapsed > {threshold}";
// produces: ... WHERE user_id = @userId AND elapsed > @thresholdOne package per provider. No string overload — the only path is $"...".
- Injection safety by construction. There is no way to pass a raw string as SQL.
Sql.Unsafe()is the explicit, auditable escape hatch. - Parameter deduplication.
$"WHERE a = {x} OR b = {x}"produces one parameter. By variable identity, not value. - Composable fragments. Build
WHEREclauses as typed fragments. Splice them in. Parameters renumber automatically. - Multi-row inserts.
InsertRowsfor VALUES composition, TVPs for SQL Server. - AOT-compatible result mapping. Mark POCOs with
[StringThingRow] partialand a source generator emits the row materializer at compile time. No reflection, no IL emit — runs underPublishAot. Scalar columns (int,string,Guid, …) map directly through the sameQueryString<T>calls — no row type needed.
Not all providers deliver the same depth of type checking. The depth depends on how much the underlying driver distinguishes between parameter types.
| Provider | Type safety | What it checks |
|---|---|---|
| Postgres | Deep | 87 typed variants — geometric types, ranges, tsvector, arrays. Each maps to a specific NpgsqlDbType. Unsupported types are a compile error. |
| SQL Server | Moderate | 17 types mapped to specific SqlDbType. Named parameters via CallerArgumentExpression. TVP support. |
| MySQL | Moderate | 22 types mapped to specific MySqlDbType. Unsigned integer support. |
| SQLite | Light | SQLite is dynamically typed — all values are stored as TEXT, INTEGER, REAL, BLOB, or NULL. The type contract is thin. The value is injection safety and parameterization ergonomics, not type checking. |
All providers share: injection safety, parameter deduplication, composable fragments, multi-row inserts, and AOT-compatible result mapping.
| Package | Built on | Installation |
|---|---|---|
StringThing.Npgsql |
Npgsql | README |
StringThing.SqlClient |
Microsoft.Data.SqlClient | README |
StringThing.MySql |
MySqlConnector | README |
StringThing.Sqlite |
Microsoft.Data.Sqlite | README |
Each provider includes AOT-compatible result mapping. Annotate row POCOs with [StringThingRow] partial and call connection.QueryString<T>($"...") — a source generator emits the materializer. See each provider's README for details.
Benchmarked against raw ADO.NET and Dapper on SQLite in-memory. Queries return one row (LIMIT 1) to keep materialization cost a small, constant baseline across implementations. Raw ADO.NET subtracted to show pure library overhead.
| Scenario | Dapper | StringThing | ST vs Dapper |
|---|---|---|---|
| QuerySingle 1 param | +1.03 us / +0.68 KB | +0.35 us / +0.18 KB | -66% time, -73% alloc |
| Query 2 params | +1.20 us / +0.89 KB | +0.25 us / +0.19 KB | -79% time, -79% alloc |
| Query 5 params | +1.64 us / +1.54 KB | +0.32 us / +0.21 KB | -80% time, -86% alloc |
| Execute insert | +0.81 us / +1.12 KB | +0.14 us / +0.22 KB | -83% time, -80% alloc |
| Scenario | Dapper | StringThing | ST vs Dapper |
|---|---|---|---|
| IN 10 items | +3.84 us / +3.87 KB | +2.04 us / +3.55 KB | -47% time, -8% alloc |
| IN 100 items | +72.83 us / +28.23 KB | +17.02 us / +24.32 KB | -77% time, -14% alloc |
StringThing is faster than Dapper and allocates less on every measured scenario after subtracting the raw ADO.NET cost. The gap widens on parameter-heavy paths — a 5-param query has 80% less time overhead and 86% less allocation, and an Execute insert has 83% less time and 80% less allocation. IN list expansion pulls the furthest ahead on time: Dapper rewrites the SQL string at runtime, StringThing builds the parameterized list directly, finishing at 77% less time on a 100-item list.
The source-generated row mapper resolves column ordinals once per call site and caches them, so subsequent queries skip all name→ordinal lookups — the dominant per-row allocation cost on most drivers.
Benchmark source: EndToEndBenchmarks.cs, InListBenchmarks.cs, CommandCreationBenchmarks.cs, InsertRowsBenchmarks.cs
StringThing ships a Roslyn analyzer that runs in your IDE and at build time:
| ID | Severity | Description |
|---|---|---|
| ST0001 | Error | Multiple interpolated SQL statements on the same source line |
| ST0002 | Error | QueryString<T> called with a T that is neither a supported scalar nor a [StringThingRow] type |
StringThing caches interpolated string templates by source location. Two SQL statements on the same line share a cache key, which causes silent incorrect parameter binding. The analyzer prevents this at compile time.
// ST0001 — move to separate lines
PostgresSql statement = condition ? $"SELECT {x}" : $"SELECT {y}";
// OK
PostgresSql statement = condition
? $"SELECT {x}"
: $"SELECT {y}";The analyzer is included automatically when you reference any StringThing package.
StringThing caches the SQL template for each call site after the first execution, keyed on CallerFilePath + CallerLineNumber. Subsequent calls at the same site skip all string building and parameter name resolution. Cache entries are never evicted — the total number is bounded by the number of unique SQL statements in your compiled code, not by runtime behavior. Each entry is roughly the size of the SQL string plus a small array for parameter metadata.
When using QueryString<T>, resolved column ordinals are also cached per (call site, T), so name → ordinal lookups run only once per source location and row type.
Not a driver. Not an ORM. Not a query builder. It sits on top of ADO.NET providers and adds parameterized interpolation they don't have.
porsager/postgres(Node/TS) — tagged template literals as the only query API. The direct inspiration for "interpolation-only, no string escape hatch."InterpolatedSql— existing .NET library for interpolated SQL parameterization. UsesFormattableString, accepts any type at runtime, database-agnostic. StringThing differs by restricting types per provider at compile time.- EF Core
FromSqlInterpolated— proves the ergonomic model works for .NET developers. StringThing removes the EF dependency.
Built by Immersus Machina