0. Introduction
TypedSql started from a very practical annoyance.
Most of the time when I write “queries” in .NET, my data is already in memory.
I have an array or a List<T>. I just want to filter and project it.
Sure, I can:
- write a
foreachloop — fast, explicit, but a little noisy - use LINQ — nice to read, but with iterator/delegate overhead
- or, in extreme cases, push everything into a database and write real SQL
TypedSql explores a different route:
What if we treated the C# type system itself as the query plan?
Instead of:
- building an expression tree at runtime, and
- interpreting it over the data,
we compile a SQL‑like string into a closed generic type that describes the whole pipeline, and then run that pipeline entirely through static methods.
This post is a deep dive into how that works, from design to parser, from literal representation to type building, and finally down to how queries execute and how they perform.
1. Design: queries as nested generic types
The central idea of TypedSql is deceptively simple:
A query is a nested generic type like
WhereSelect<TRow, …, Stop<...>>.
That leads to a few concrete design decisions.
1.1 Execution plan in the type system
In TypedSql, every compiled query becomes a closed generic pipeline type. The building blocks are:
Where<TRow, TPredicate, TNext, TResult, TRoot>Select<TRow, TProjection, TNext, TMiddle, TResult, TRoot>WhereSelect<TRow, TPredicate, TProjection, TNext, TMiddle, TResult, TRoot>Stop<TResult, TRoot>
Each of these implements:
internal interface IQueryNode<TRow, TResult, TRoot>
{
static abstract void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime);
static abstract void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime);
}
Run is the outer loop, Process is the per‑row logic.
For example, the Where node looks like this:
internal readonly struct Where<TRow, TPredicate, TNext, TResult, TRoot>
: IQueryNode<TRow, TResult, TRoot>
where TPredicate : IFilter<TRow>
where TNext : IQueryNode<TRow, TResult, TRoot>
{
public static void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime)
{
for (var i = 0; i < rows.Length; i++)
{
Process(in rows[i], ref runtime);
}
}
public static void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime)
{
if (TPredicate.Evaluate(in row))
{
TNext.Process(in row, ref runtime);
}
}
}
The important part:
- The shape of the pipeline lives entirely in the type arguments.
- Each node is a
structwith only static methods — no instances, no virtual calls.
From the JIT’s perspective, once the generic type is fixed, this is just a static call graph.
1.2 Columns and projections as value types
Queries run over some row type TRow that you define (a record, class, struct…).
Columns implement:
internal interface IColumn<TRow, TValue>
{
static abstract string Identifier { get; }
static abstract TValue Get(in TRow row);
}
For example:
internal readonly struct PersonNameColumn : IColumn<Person, string>
{
public static string Identifier => "Name";
public static string Get(in Person row) => row.Name;
}
Projections implement:
internal interface IProjection<TRow, TResult>
{
static abstract TResult Project(in TRow row);
}
A simple “project a column” implementation is:
internal readonly struct ColumnProjection<TColumn, TRow, TValue>
: IProjection<TRow, TValue>
where TColumn : IColumn<TRow, TValue>
{
public static TValue Project(in TRow row) => TColumn.Get(row);
}
For multiple selected columns, TypedSql uses specialized projections that construct ValueTuples:
internal readonly struct ValueTupleProjection<TRow, TColumn1, TValue1>
: IProjection<TRow, ValueTuple<TValue1>>
where TColumn1 : IColumn<TRow, TValue1>
{
public static ValueTuple<TValue1> Project(in TRow row)
=> new(TColumn1.Get(row));
}
// … up to 7 columns, and then a recursive “Rest” projected by another IProjection
Again, everything is static and struct‑based.
1.3 Filters as static structs
Filters implement:
internal interface IFilter<TRow>
{
static abstract bool Evaluate(in TRow row);
}
Comparison filters combine a column and a literal:
internal readonly struct EqualsFilter<TRow, TColumn, TLiteral, TValue> : IFilter<TRow>
where TColumn : IColumn<TRow, TValue>
where TLiteral : ILiteral<TValue>
where TValue : IEquatable<TValue>, IComparable<TValue>
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool Evaluate(in TRow row)
{
if (typeof(TValue).IsValueType)
{
return TColumn.Get(row).Equals(TLiteral.Value);
}
else
{
var left = TColumn.Get(row);
var right = TLiteral.Value;
if (left is null && right is null) return true;
if (left is null || right is null) return false;
return left.Equals(right);
}
}
}
Here we handle null differently depending on whether TValue is a value type or a reference type. .NET’s JIT can recognize this pattern and specialize it for each case, generating separate code paths for value types and reference types, so in practice there is no real branching overhead.
Similarly for GreaterThanFilter, LessThanFilter, GreaterOrEqualFilter, LessOrEqualFilter, NotEqualFilter.
Boolean composition is also done at the type level:
internal readonly struct AndFilter<TRow, TLeft, TRight> : IFilter<TRow>
where TLeft : IFilter<TRow>
where TRight : IFilter<TRow>
{
public static bool Evaluate(in TRow row)
=> TLeft.Evaluate(in row) && TRight.Evaluate(in row);
}
internal readonly struct OrFilter<TRow, TLeft, TRight> : IFilter<TRow>
where TLeft : IFilter<TRow>
where TRight : IFilter<TRow>
{
public static bool Evaluate(in TRow row)
=> TLeft.Evaluate(in row) || TRight.Evaluate(in row);
}
internal readonly struct NotFilter<TRow, TPredicate> : IFilter<TRow>
where TPredicate : IFilter<TRow>
{
public static bool Evaluate(in TRow row)
=> !TPredicate.Evaluate(in row);
}
So a WHERE clause ends up as a tree of generic filter types, each with a static Evaluate.
1.4 Special handling for strings: ValueString
In .NET, string is a reference type, which creates some friction for TypedSql: the runtime uses generic sharing for reference types (including string), so you end up paying a small but non‑zero cost for dictionary lookups on generic instantiations. TypedSql tries to keep the hot path as value‑type‑only as possible.
To that end, it wraps strings in a small value type:
internal readonly struct ValueString(string? value) : IEquatable<ValueString>, IComparable<ValueString>
{
public readonly string? Value = value;
public int CompareTo(ValueString other)
=> string.Compare(Value, other.Value, StringComparison.Ordinal);
public bool Equals(ValueString other)
{
return string.Equals(Value, other.Value, StringComparison.Ordinal);
}
public override string? ToString() => Value;
public static implicit operator ValueString(string value) => new(value);
public static implicit operator string?(ValueString value) => value.Value;
}
And it adapts string columns using ValueStringColumn:
internal readonly struct ValueStringColumn<TColumn, TRow>
: IColumn<TRow, ValueString>
where TColumn : IColumn<TRow, string>
{
public static string Identifier => TColumn.Identifier;
public static ValueString Get(in TRow row)
=> new(TColumn.Get(in row));
}
Internally, all string columns are normalized to ValueString, which has a couple of benefits:
- the hot path stays as much value‑type‑only as possible, reducing generic‑sharing overhead
- string comparisons are consistently ordinal and easy for the JIT to optimize
-
ValueStringstill converts to/fromstringimplicitly, so the API surface remains ergonomic
2. Parsing a small SQL dialect
TypedSql supports a deliberately small SQL subset that’s tuned for single‑table, in‑memory querying:
SELECT * FROM $SELECT col FROM $SELECT col1, col2, ... FROM $-
WHEREwith:- comparison:
=,!=,>,<,>=,<= - boolean:
AND,OR,NOT - parentheses
- comparison:
- literals:
- integers (e.g.,
42) - floats (e.g.,
123.45) - booleans (
true,false) - single‑quoted strings (
'Seattle',''inside as escape) null
- integers (e.g.,
- column names are case‑insensitive
-
$is a placeholder for “the current row source”
The parser does:
- Tokenization of the input SQL.
- Construction of a small AST:
-
ParsedQuery— selection + optional where -
Selection—SelectAllor a list of column identifiers -
WhereExpression— one of:ComparisonExpressionAndExpressionOrExpressionNotExpression
-
LiteralValue— stores bothKindand the actual value:-
LiteralKind.Integer+IntValue -
LiteralKind.Float+FloatValue -
LiteralKind.Boolean+BoolValue -
LiteralKind.String+StringValue(asstring?) LiteralKind.Null
-
-
At this stage, there’s no knowledge of C# types or columns — it’s just SQL structure.
Type checking and “does this literal make sense for that column?” is done later, during compilation.
3. Literals as types — including string literals
One of the distinctive features of TypedSql is that literals are represented as types, not just values.
All literal types implement:
internal interface ILiteral<T>
{
static abstract T Value { get; }
}
This applies to:
- integers (
int) - floats (
float) - characters (
char) - booleans (
bool) - strings (
ValueString, wrappingstring?)
3.1 Hex digits and numeric literals
Numeric literals are encoded using hex digits and bit operations.
First, there’s an IHex interface and Hex0–HexF struct types:
internal interface IHex { static abstract int Value { get; } }
internal readonly struct Hex0 : IHex { public static int Value => 0; }
// ...
internal readonly struct HexF : IHex { public static int Value => 15; }
Then, integers:
internal readonly struct Int<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<int>
where H7 : IHex
// ...
where H0 : IHex
{
public static int Value
=> (H7.Value << 28)
| (H6.Value << 24)
| (H5.Value << 20)
| (H4.Value << 16)
| (H3.Value << 12)
| (H2.Value << 8)
| (H1.Value << 4)
| H0.Value;
}
Floats use the same eight‑nibble pattern but bit‑cast back to float:
internal readonly struct Float<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<float>
where H7 : IHex
// ...
{
public static float Value
=> Unsafe.BitCast<int, float>(
(H7.Value << 28)
| (H6.Value << 24)
| (H5.Value << 20)
| (H4.Value << 16)
| (H3.Value << 12)
| (H2.Value << 8)
| (H1.Value << 4)
| H0.Value);
}
Characters (char) are four hex digits:
internal readonly struct Char<H3, H2, H1, H0> : ILiteral<char>
where H3 : IHex
// ...
{
public static char Value
=> (char)((H3.Value << 12)
| (H2.Value << 8)
| (H1.Value << 4)
| H0.Value);
}
3.2 String literals: type‑level linked lists
String literals are more interesting.
TypedSql builds them out of a type‑level linked list of characters, described by IStringNode:
internal interface IStringNode
{
static abstract int Length { get; }
static abstract void Write(Span<char> destination, int index);
}
There are three implementations:
-
StringEnd— represents the end of the string (length 0) -
StringNull— represents a null string (length -1) -
StringNode<TChar, TNext>— one character plus the rest
internal readonly struct StringEnd : IStringNode
{
public static int Length => 0;
public static void Write(Span<char> destination, int index) { }
}
internal readonly struct StringNull : IStringNode
{
public static int Length => -1;
public static void Write(Span<char> destination, int index) { }
}
internal readonly struct StringNode<TChar, TNext> : IStringNode
where TChar : ILiteral<char>
where TNext : IStringNode
{
public static int Length => 1 + TNext.Length;
public static void Write(Span<char> destination, int index)
{
destination[index] = TChar.Value;
TNext.Write(destination, index + 1);
}
}
Now, given an IStringNode implementation, we can build a ValueString via:
internal readonly struct StringLiteral<TString> : ILiteral<ValueString>
where TString : IStringNode
{
public static ValueString Value => Cache.Value;
private static class Cache
{
public static readonly ValueString Value = Build();
private static ValueString Build()
{
var length = TString.Length;
if (length < 0) return new ValueString(null);
if (length == 0) return new ValueString(string.Empty);
var chars = new char[length];
TString.Write(chars.AsSpan(), 0);
return new string(chars, 0, length);
}
}
}
So StringLiteral<TString> is an ILiteral<ValueString> whose Value is computed once, statically.
3.2.1 How a string becomes a type
The factory LiteralTypeFactory.CreateStringLiteral handles string literals:
public static Type CreateStringLiteral(string? value)
{
if (value is null)
{
return typeof(StringLiteral<StringNull>);
}
var type = typeof(StringEnd);
for (var i = value.Length - 1; i >= 0; i--)
{
var charType = CreateCharType(value[i]); // Char<...>
type = typeof(StringNode<,>).MakeGenericType(charType, type);
}
return typeof(StringLiteral<>).MakeGenericType(type);
}
Let’s walk through an example: the literal 'Seattle'.
- Parse phase sees
'Seattle'and produces aLiteralValuewith:Kind == LiteralKind.StringStringValue == "Seattle"
- The compiler decides the corresponding column is a string column — so its runtime type is
ValueString. -
CreateStringLiteral("Seattle")runs:- start with
type = typeof(StringEnd) - iterate characters from right to left:
-
'e'→Char<…>type (4 hex digits that encode Unicode code point) type = StringNode<Char<'e'>, StringEnd>-
'l'→Char<…> type = StringNode<Char<'l'>, StringNode<Char<'e'>, StringEnd>>- repeat for each character:
't','t','a','e','S'
-
- end result is something conceptually like:
StringNode<Char<'S'>, StringNode<Char<'e'>, StringNode<Char<'a'>, StringNode<Char<'t'>, StringNode<Char<'t'>, StringNode<Char<'l'>, StringNode<Char<'e'>, StringEnd>>>>>>>> - start with
Finally,
StringLiteral<>wraps that node type:
StringLiteral<
StringNode<Char<'S'>,
StringNode<Char<'e'>,
...
>
>
>
This single closed generic type is the string literal 'Seattle'. When a filter needs the value, it calls TLiteral.Value, which reconstructs a ValueString("Seattle") through TString.Length and TString.Write.
3.2.2 Null string literals
null is special:
- If you write
WHERE Team != null, the parser emitsLiteralKind.Null. - For a string column,
CreateStringLiteral(null)returnstypeof(StringLiteral<StringNull>). -
StringNull.Length == -1, soStringLiteral<StringNull>.Valuereturnsnew ValueString(null).
This way, null and "" are clearly distinguishable at the type level and runtime.
3.3 Literal factory
All of these encodings are wired together by LiteralTypeFactory:
internal static class LiteralTypeFactory
{
public static Type CreateIntLiteral(int value) { ... }
public static Type CreateFloatLiteral(float value) { ... }
public static Type CreateBoolLiteral(bool value) { ... }
public static Type CreateStringLiteral(string? value) { ... }
}
The SQL compiler calls these based on:
- the runtime column type (
int,float,bool,ValueString) - the literal kind (
Integer,Float,Boolean,String,Null)
The end result: every literal in the WHERE clause is a concrete ILiteral<T> type with its value embedded in the type arguments.
4. Building the pipeline type
By now we have:
- a parsed query (
SELECT+WHERE) - a schema mapping column names to
IColumn<TRow, TValue>implementations - a way to map literals to
ILiteral<T>types
The compiler’s job is to turn this into:
- a closed pipeline type
TPipelineimplementingIQueryNode<TRow, TRuntimeResult, TRoot> - a runtime result type
TRuntimeResult - a public result type
TPublicResult
4.1 Handling SELECT
First, the SELECT part.
SELECT *
The simplest case: SELECT * FROM $.
- Both runtime and public result types are
TRow. - The pipeline tail is just a
Stop<TRow, TRow>node.
Conceptually:
TRuntimeResult = typeof(TRow);
TPublicResult = typeof(TRow);
TPipelineTail = typeof(Stop<,>).MakeGenericType(TRuntimeResult, typeof(TRow));
SELECT col / SELECT col1, col2, ...
For projections:
-
SELECT col:- resolve the column into
ColumnMetadata - decide its runtime value type:
- if
ValueType != string, runtime type is the same - if
ValueType == string, runtime type isValueString - build a
ColumnProjection<TRuntimeColumn, TRow, TRuntimeValue>node
- resolve the column into
-
SELECT col1, col2, ...:- resolve each column
- build a
ValueTupleProjectionthat constructs aValueTuple<...>of runtime value types - separately record the public
ValueTuple<...>of declared CLR types
Finally, regardless of arity, a Select node is prepended before the Stop:
Select<TRow, TProjection, Stop<...>, TMiddle, TRuntimeResult, TRoot> → Stop<...>
This node calls the projection’s static Project and forwards the result to Stop.Process.
4.2 Handling WHERE
The WHERE clause is compiled recursively.
Boolean structure
Given a parsed WhereExpression tree:
-
A AND B→AndFilter<TRow, TA, TB> -
A OR B→OrFilter<TRow, TA, TB> -
NOT A→NotFilter<TRow, TA>
The compiler maps each node to a filter type by calling itself recursively:
Type BuildPredicate<TRow>(WhereExpression expr)
{
return expr switch
{
ComparisonExpression cmpExpr => BuildComparisonPredicate<TRow>(cmpExpr),
AndExpression andExpr => typeof(AndFilter<,,>).MakeGenericType(typeof(TRow), BuildPredicate<TRow>(andExpr.Left), BuildPredicate<TRow>(andExpr.Right)),
OrExpression orExpr => typeof(OrFilter<,,>).MakeGenericType(typeof(TRow), BuildPredicate<TRow>(orExpr.Left), BuildPredicate<TRow>(orExpr.Right)),
NotExpression notExpr => typeof(NotFilter<,>).MakeGenericType(typeof(TRow), BuildPredicate<TRow>(notExpr.Expression)),
_ => throw …
};
}
Leaf comparisons
Each comparison like:
City = 'Seattle'
Salary >= 180000
Team != null
becomes a concrete filter type:
Type BuildComparisonPredicate<TRow>(ComparisonExpression comparison)
{
var rowType = typeof(TRow);
var column = SchemaRegistry<TRow>.ResolveColumn(comparison.ColumnIdentifier);
var runtimeColumnType = column.GetRuntimeColumnType(rowType);
var runtimeColumnValueType = column.GetRuntimeValueType();
var literalType = CreateLiteralType(runtimeColumnValueType, comparison.Literal);
var filterDefinition = comparison.Operator switch
{
ComparisonOperator.Equals => typeof(EqualsFilter<,,,>),
ComparisonOperator.GreaterThan => typeof(GreaterThanFilter<,,,>),
ComparisonOperator.LessThan => typeof(LessThanFilter<,,,>),
ComparisonOperator.GreaterOrEqual=> typeof(GreaterOrEqualFilter<,,,>),
ComparisonOperator.LessOrEqual => typeof(LessOrEqualFilter<,,,>),
ComparisonOperator.NotEqual => typeof(NotEqualFilter<,,,>),
_ => throw …
};
return filterDefinition.MakeGenericType(
rowType, runtimeColumnType, literalType, runtimeColumnValueType);
}
So, for example, City = 'Seattle' against a string column:
- runtime column type is
ValueStringColumn<PersonCityColumn, Person> - runtime value type is
ValueString - literal type is
StringLiteral<SomeStringNode<…>>(viaCreateStringLiteral("Seattle"))
The final filter type is:
EqualsFilter<Person,
ValueStringColumn<PersonCityColumn, Person>,
StringLiteral<...>,
ValueString>
Finally, a Where node is attached:
Where<TRow, TPredicate, TNext, TRuntimeResult, TRoot> → ...
4.3 Fusing Where and Select
The resulting pipeline is correct but not optimal: Where and Select are separate stages.
There’s a tiny optimizer that looks for patterns like:
Where<TRow, TPredicate, Select<TRow, TProjection, TNext, TMiddle, TResult, TRoot>, TResult, TRoot>
and fuses them into:
WhereSelect<TRow, TPredicate, TProjection, TNext', TMiddle, TResult, TRoot>
The fused node:
internal readonly struct WhereSelect<TRow, TPredicate, TProjection, TNext, TMiddle, TResult, TRoot>
: IQueryNode<TRow, TResult, TRoot>
where TPredicate : IFilter<TRow>
where TProjection : IProjection<TRow, TMiddle>
where TNext : IQueryNode<TMiddle, TResult, TRoot>
{
public static void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime)
{
for (var i = 0; i < rows.Length; i++)
{
Process(in rows[i], ref runtime);
}
}
public static void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime)
{
if (TPredicate.Evaluate(in row))
{
var projected = TProjection.Project(in row);
TNext.Process(in projected, ref runtime);
}
}
}
Common queries like:
SELECT Name FROM $ WHERE City = 'Seattle'
end up as:
WhereSelect<...> → Stop<...>
i.e. a single pass: filter and projection in the same tight loop. Moreover, our optimizer can also recognize more complex nested structures and fuse Where and Select together wherever possible to reduce intermediate steps and improve performance. This doesn’t require any sophisticated optimization algorithms — we just pull out the generic type arguments and plug them into the fused type, which makes the implementation very straightforward.
5. Result conversion
Once the static pipeline has processed the rows, we need to expose the results to the caller.
The generic entry point for a query is:
internal static class QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult>
where TPipeline : IQueryNode<TRow, TRuntimeResult, TRow>
{
public static IReadOnlyList<TPublicResult> Execute(ReadOnlySpan<TRow> rows)
{
var runtime = new QueryRuntime<TRuntimeResult>(rows.Length);
TPipeline.Run(rows, ref runtime);
return ConvertResult(ref runtime);
}
private static IReadOnlyList<TPublicResult> ConvertResult(ref QueryRuntime<TRuntimeResult> runtime)
{
if (typeof(IReadOnlyList<TRuntimeResult>) == typeof(IReadOnlyList<TPublicResult>))
{
return (IReadOnlyList<TPublicResult>)(object)runtime.Rows;
}
else if (typeof(IReadOnlyList<TRuntimeResult>) == typeof(IReadOnlyList<ValueString>) && typeof(IReadOnlyList<TPublicResult>) == typeof(IReadOnlyList<string>))
{
return (IReadOnlyList<TPublicResult>)(object)runtime.AsStringRows();
}
else if (RuntimeFeature.IsDynamicCodeSupported && typeof(TRuntimeResult).IsGenericType && typeof(TPublicResult).IsGenericType)
{
return runtime.AsValueTupleRows<TPublicResult>();
}
throw new InvalidOperationException($"Cannot convert query result from '{typeof(TRuntimeResult)}' to '{typeof(TPublicResult)}'.");
}
}
So there are three main cases:
Runtime and public types are identical
→ just returnRows.Runtime uses
ValueString, public expectsstring
→ useAsStringRows, which wraps the underlyingValueString[]and returnsstring?via implicit conversion.Both are
ValueTupleshapes
→ useAsValueTupleRows<TPublicResult>(), which delegates toValueTupleConvertHelper.
5.3 ValueTupleConvertHelper: copying fields with dynamic IL
ValueTupleConvertHelper<TPublicResult, TRuntimeResult> is responsible for:
- copying one
ValueTupleinto another with compatible shapes - handling
string↔ValueStringconversions on individual fields - recursing into nested
ValueTuple<..., TRest>tails
It uses a DynamicMethod to generate a copy method at type‑initializer time:
internal static class ValueTupleConvertHelper<TPublicResult, TRuntimeResult>
{
private delegate void CopyDelegate(ref TPublicResult dest, ref readonly TRuntimeResult source);
private static readonly CopyDelegate _helper = default!;
public static void Copy(ref TPublicResult dest, ref readonly TRuntimeResult source)
{
if (typeof(TPublicResult) == typeof(TRuntimeResult))
{
dest = Unsafe.As<TRuntimeResult, TPublicResult>(ref Unsafe.AsRef(in source));
}
else
{
_helper.Invoke(ref dest, in source);
}
}
static ValueTupleConvertHelper()
{
// Create DynamicMethod and IL to copy fields, including string <-> ValueString
// and nested Rest fields.
}
}
The generated IL:
- walks the fields
Item1…Item7andRestofValueTuple - for each field:
- if field types are identical, just copy
- if one side is
stringand the other isValueString, convert via wrapping/unwrapping
- recurses through nested
Restuntil the end
This means the runtime can store a tuple of internal types like (ValueString, int, ValueString, …) while the public type can be (string, int, string, …) — and they are bridged cheaply.
Here we use DynamicMethod for dynamic IL generation, which may not be available in AOT or other restricted environments. TypedSql checks for dynamic code generation support at runtime and only enables this path when available; otherwise, it falls back to requiring that runtime and public result types match exactly.
6. Putting it together: compile and execute
From the user’s point of view, the entry point looks like this:
var compiled = QueryEngine.Compile<Person, string>(
"SELECT Name FROM $ WHERE City != 'Seattle'");
Internally, Compile<TRow, TResult>:
- Parses the SQL into a
ParsedQuery. - Calls the SQL compiler to get:
- the pipeline type
TPipeline TRuntimeResultTPublicResult
- the pipeline type
- Asserts that
TPublicResultmatches theTResultyou requested. - Constructs
QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult>. - Locates its static
Execute(ReadOnlySpan<TRow>)method. - Converts it to a delegate and returns a
CompiledQuery<TRow, TResult>wrapper.
CompiledQuery<TRow, TResult> then just holds:
private readonly Func<ReadOnlySpan<TRow>, IReadOnlyList<TResult>> _entryPoint
= executeMethod.CreateDelegate<Func<ReadOnlySpan<TRow>, IReadOnlyList<TResult>>>();
and exposes:
public IReadOnlyList<TResult> Execute(ReadOnlySpan<TRow> rows)
=> _entryPoint(rows);
Thanks to the optimizations around escape analysis, devirtualization and inlining for delegates in .NET 10, this abstraction does not add measurable overhead.
From the JIT’s perspective, once Compile has run, every Execute is just:
- a direct static call
- into a generic method where all type arguments are closed
- that calls a static pipeline of struct nodes, filters, and projections.
7. Usage, tests, and benchmarks
7.1 Usage pattern
The typical workflow with TypedSql is:
- Define your row type:
public sealed record Person(
int Id,
string Name,
int Age,
string City,
float Salary,
string Department,
bool IsManager,
int YearsAtCompany,
string Country,
string? Team,
string Level);
- Implement
IColumn<Person, TValue>for each column you want to query on. - Register them in a schema for
Person. - Compile and run queries, for example:
// Compile once
var wellPaidManagers = QueryEngine.Compile<Person, Person>(
"SELECT * FROM $ " +
"WHERE Department = 'Engineering' " +
"AND IsManager = true " +
"AND YearsAtCompany >= 5 " +
"AND Salary > 170000 " +
"AND Country = 'US'");
// Execute over different data sets
var result = wellPaidManagers.Execute(allPeople.AsSpan());
Or a projection query returning tuples:
var seniorTitles = QueryEngine.Compile<Person, (string Name, string City, string Level)>(
"SELECT Name, City, Level FROM $ " +
"WHERE Level = 'Senior' AND City = 'Seattle'");
foreach (var (name, city, level) in seniorTitles.Execute(allPeople.AsSpan()))
{
Console.WriteLine($"{name} in {city} [{level}]");
}
All the heavy lifting — parsing, literal encoding, type‑level pipeline generation — happens once at compile time of the query. Every .Execute after that is just running a specialized static pipeline.
7.2 Benchmarks
TypedSql’s motivation was not “be clever with types for the sake of it”, but to see how close we can get to a handwritten loop while keeping a SQL‑like surface.
A simple benchmark compares three approaches over the same in‑memory dataset:
- a TypedSql query
- an equivalent LINQ query
- a handwritten
foreach
The workload:
- filter rows where
City == "Seattle" - return their
Ids
TypedSql compiles to a pipeline like this (for a query that filters City == "Seattle" and projects Id):
QueryProgram<
Person,
WhereSelect<
Person,
EqualsFilter<
Person,
ValueStringColumn<PersonCityColumn, Person>,
'Seattle',
ValueString
>,
ColumnProjection<PersonIdColumn, Person, Int32>,
Stop<Int32, Person>,
Int32,
Int32,
Person>,
Int32,
Int32
>
RyuJIT turns that into machine code roughly like this (trimmed for brevity):
G_M000_IG01: ; prologue
push r15
push r14
push rdi
push rsi
push rbp
push rbx
sub rsp, 40
mov rbx, rcx
G_M000_IG02: ; allocate result array
mov esi, dword ptr [rbx+0x08]
mov edx, esi
mov rcx, 0x7FFE71F29558
call CORINFO_HELP_NEWARR_1_VC
mov rdi, rax
xor ebp, ebp
mov rbx, bword ptr [rbx]
test esi, esi
jle SHORT G_M000_IG06
G_M000_IG03: ; init loop index
xor r14d, r14d
G_M000_IG04: ; loop body
lea r15, bword ptr [rbx+r14]
mov rcx, gword ptr [r15+0x08]
mov rdx, 0x16EB0400D30
mov rdx, gword ptr [rdx]
mov rdx, gword ptr [rdx+0x08]
cmp rcx, rdx
je G_M000_IG12
test rcx, rcx
je SHORT G_M000_IG05
test rdx, rdx
je SHORT G_M000_IG05
mov r8d, dword ptr [rcx+0x08]
cmp r8d, dword ptr [rdx+0x08]
je SHORT G_M000_IG08
G_M000_IG05: ; advance index
add r14, 72
dec esi
jne SHORT G_M000_IG04
G_M000_IG06: ; wrap result
mov rcx, 0x7FFE72227600
call CORINFO_HELP_NEWSFAST
mov rbx, rax
lea rcx, bword ptr [rbx+0x08]
mov rdx, rdi
call CORINFO_HELP_ASSIGN_REF
mov dword ptr [rbx+0x10], ebp
mov rax, rbx
G_M000_IG07: ; epilogue
add rsp, 40
pop rbx
pop rbp
pop rsi
pop rdi
pop r14
pop r15
ret
G_M000_IG08: ; string length compare
lea rax, bword ptr [rcx+0x0C]
add rdx, 12
mov ecx, dword ptr [rcx+0x08]
add ecx, ecx
mov r8d, ecx
cmp r8, 10
je SHORT G_M000_IG10
G_M000_IG09: ; slow path compare
mov rcx, rax
call [System.SpanHelpers:SequenceEqual(byref,byref,nuint):bool]
jmp SHORT G_M000_IG11
G_M000_IG10: ; fast path compare for length 10
mov rcx, qword ptr [rax]
mov rax, qword ptr [rax+0x02]
mov r8, qword ptr [rdx]
xor rcx, r8
xor rax, qword ptr [rdx+0x02]
or rcx, rax
sete al
movzx rax, al
G_M000_IG11: ; branch on comparison result
test eax, eax
je SHORT G_M000_IG05
G_M000_IG12: ; write matching Id
mov ecx, dword ptr [r15+0x30]
lea rax, bword ptr [rdi+0x10]
lea edx, [rbp+0x01]
mov r15d, edx
movsxd rdx, ebp
mov dword ptr [rax+4*rdx], ecx
mov ebp, r15d
jmp G_M000_IG05
There are a few interesting details here:
- In
G_M000_IG08, the comparecmp r8, 10bakes the literal length of'Seattle'into the code, andG_M000_IG10is a dedicated fast path for strings of that length. - In
G_M000_IG05, theadd r14, 72usessizeof(Person)as a constant step, and the loop counter is turned into a simple decrement (dec esi), which minimizes branches.
Semantically, the loop is equivalent to:
int length = elements.Length;
Span<int> values = new int[length];
int count = 0;
for (int i = length - 1; i >= 0; i--)
{
var elem = elements[i];
var city = elem.City;
if (city == null)
continue;
if (city.Length == 10 && city == "Seattle")
{
values[length - 1 - count] = elem.Id;
count++;
}
}
return values[..count];
In other words, the abstractions in TypedSql compile down to something extremely close to a hand‑written loop, with constants for both the string literal and row size baked into the machine code.
The benchmark numbers back this up:
| Method | Mean | Error | StdDev | Gen0 | Code Size | Allocated |
|---|---|---|---|---|---|---|
| TypedSql | 10.953 ns | 0.0250 ns | 0.0195 ns | 0.0051 | 111 B | 80 B |
| Linq | 27.030 ns | 0.1277 ns | 0.1067 ns | 0.0148 | 3,943 B | 232 B |
| Foreach | 9.429 ns | 0.0417 ns | 0.0326 ns | 0.0046 | 407 B | 72 B |
This matches the internal structure we designed:
- the query pipeline is expressed at the type level, and its shape is fixed at compile time
- columns, projections, and filters are all value types with static methods
- strings go through the
ValueStringhot path - literals are embedded into type parameters via
ILiteral<T> - all of this lets the JIT specialize, unroll, and inline aggressively, producing machine code that is almost indistinguishable from a handwritten loop
8. Closing thoughts
TypedSql is a small in‑memory query engine experiment. It focuses on a very specific question: how much query logic can we push into the C# type system, and how fast can .NET run such type‑driven code?
In TypedSql, we:
- represent columns, projections, and filters as
structs with static methods, constrained by interfaces with static abstract members - compose them into nested generic pipeline nodes (
Where,Select,WhereSelect,Stop) - encode numeric and string literals as types (
ILiteral<T>) so that literal values live in type parameters
The end result is a small, SQL‑like in‑memory query engine. To the JIT, it is just a collection of highly optimizable, type‑specialized loops; to you, it still looks and feels like writing SQL.
So the answer is yes: the .NET type system is powerful enough to express Turing‑complete logic, and with help from the JIT, it can generate very efficient code.
Looking ahead, the same ideas could be applied to query engines, DSL compilers, and even language runtimes: use the type system to encode structure, let the JIT do the heavy lifting, and, when needed, emit the constructed types as source and compile them with NativeAOT to get native binaries. You get both JIT and AOT from the same codebase, and—unlike C++ templates or constexpr—you can still feed it dynamic input at runtime.
The project is open source on GitHub: TypedSql, where you can explore the full implementation, run the benchmarks, and try it out with your own data!
Top comments (0)