DEV Community

Steve
Steve

Posted on

TypedSql: Turning the C# Type System into a High-Performance SQL Query Engine

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 foreach loop — 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);
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The important part:

  • The shape of the pipeline lives entirely in the type arguments.
  • Each node is a struct with 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);
}
Enter fullscreen mode Exit fullscreen mode

For example:

internal readonly struct PersonNameColumn : IColumn<Person, string>
{
    public static string Identifier => "Name";

    public static string Get(in Person row) => row.Name;
}
Enter fullscreen mode Exit fullscreen mode

Projections implement:

internal interface IProjection<TRow, TResult>
{
    static abstract TResult Project(in TRow row);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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
  • ValueString still converts to/from string implicitly, 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 $
  • WHERE with:
    • comparison: =, !=, >, <, >=, <=
    • boolean: AND, OR, NOT
    • parentheses
  • literals:
    • integers (e.g., 42)
    • floats (e.g., 123.45)
    • booleans (true, false)
    • single‑quoted strings ('Seattle', '' inside as escape)
    • null
  • column names are case‑insensitive
  • $ is a placeholder for “the current row source”

The parser does:

  1. Tokenization of the input SQL.
  2. Construction of a small AST:
    • ParsedQuery — selection + optional where
    • SelectionSelectAll or a list of column identifiers
    • WhereExpression — one of:
      • ComparisonExpression
      • AndExpression
      • OrExpression
      • NotExpression
    • LiteralValue — stores both Kind and the actual value:
      • LiteralKind.Integer + IntValue
      • LiteralKind.Float + FloatValue
      • LiteralKind.Boolean + BoolValue
      • LiteralKind.String + StringValue (as string?)
      • 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; }
}
Enter fullscreen mode Exit fullscreen mode

This applies to:

  • integers (int)
  • floats (float)
  • characters (char)
  • booleans (bool)
  • strings (ValueString, wrapping string?)

3.1 Hex digits and numeric literals

Numeric literals are encoded using hex digits and bit operations.

First, there’s an IHex interface and Hex0HexF 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; }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Let’s walk through an example: the literal 'Seattle'.

  1. Parse phase sees 'Seattle' and produces a LiteralValue with:
    • Kind == LiteralKind.String
    • StringValue == "Seattle"
  2. The compiler decides the corresponding column is a string column — so its runtime type is ValueString.
  3. 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>>>>>>>>
    
  4. Finally, StringLiteral<> wraps that node type:

   StringLiteral<
     StringNode<Char<'S'>,
       StringNode<Char<'e'>,
         ...
       >
     >
   >
Enter fullscreen mode Exit fullscreen mode

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 emits LiteralKind.Null.
  • For a string column, CreateStringLiteral(null) returns typeof(StringLiteral<StringNull>).
  • StringNull.Length == -1, so StringLiteral<StringNull>.Value returns new 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) { ... }
}
Enter fullscreen mode Exit fullscreen mode

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 TPipeline implementing IQueryNode<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));
Enter fullscreen mode Exit fullscreen mode

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 is ValueString
    • build a ColumnProjection<TRuntimeColumn, TRow, TRuntimeValue> node
  • SELECT col1, col2, ...:

    • resolve each column
    • build a ValueTupleProjection that constructs a ValueTuple<...> 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<...>
Enter fullscreen mode Exit fullscreen mode

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 BAndFilter<TRow, TA, TB>
  • A OR BOrFilter<TRow, TA, TB>
  • NOT ANotFilter<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 
    };
}
Enter fullscreen mode Exit fullscreen mode

Leaf comparisons

Each comparison like:

City = 'Seattle'
Salary >= 180000
Team != null
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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<…>> (via CreateStringLiteral("Seattle"))

The final filter type is:

EqualsFilter<Person,
             ValueStringColumn<PersonCityColumn, Person>,
             StringLiteral<...>,
             ValueString>
Enter fullscreen mode Exit fullscreen mode

Finally, a Where node is attached:

Where<TRow, TPredicate, TNext, TRuntimeResult, TRoot> → ...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Common queries like:

SELECT Name FROM $ WHERE City = 'Seattle'
Enter fullscreen mode Exit fullscreen mode

end up as:

WhereSelect<...> → Stop<...>
Enter fullscreen mode Exit fullscreen mode

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)}'.");
    }
}
Enter fullscreen mode Exit fullscreen mode

So there are three main cases:

  1. Runtime and public types are identical

    → just return Rows.

  2. Runtime uses ValueString, public expects string

    → use AsStringRows, which wraps the underlying ValueString[] and returns string? via implicit conversion.

  3. Both are ValueTuple shapes

    → use AsValueTupleRows<TPublicResult>(), which delegates to ValueTupleConvertHelper.

5.3 ValueTupleConvertHelper: copying fields with dynamic IL

ValueTupleConvertHelper<TPublicResult, TRuntimeResult> is responsible for:

  • copying one ValueTuple into another with compatible shapes
  • handling stringValueString conversions 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.
    }
}
Enter fullscreen mode Exit fullscreen mode

The generated IL:

  • walks the fields Item1Item7 and Rest of ValueTuple
  • for each field:
    • if field types are identical, just copy
    • if one side is string and the other is ValueString, convert via wrapping/unwrapping
  • recurses through nested Rest until 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'");
Enter fullscreen mode Exit fullscreen mode

Internally, Compile<TRow, TResult>:

  1. Parses the SQL into a ParsedQuery.
  2. Calls the SQL compiler to get:
    • the pipeline type TPipeline
    • TRuntimeResult
    • TPublicResult
  3. Asserts that TPublicResult matches the TResult you requested.
  4. Constructs QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult>.
  5. Locates its static Execute(ReadOnlySpan<TRow>) method.
  6. 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>>>();
Enter fullscreen mode Exit fullscreen mode

and exposes:

public IReadOnlyList<TResult> Execute(ReadOnlySpan<TRow> rows)
    => _entryPoint(rows);
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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);
Enter fullscreen mode Exit fullscreen mode
  1. Implement IColumn<Person, TValue> for each column you want to query on.
  2. Register them in a schema for Person.
  3. 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());
Enter fullscreen mode Exit fullscreen mode

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}]");
}
Enter fullscreen mode Exit fullscreen mode

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
>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

There are a few interesting details here:

  • In G_M000_IG08, the compare cmp r8, 10 bakes the literal length of 'Seattle' into the code, and G_M000_IG10 is a dedicated fast path for strings of that length.
  • In G_M000_IG05, the add r14, 72 uses sizeof(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];
Enter fullscreen mode Exit fullscreen mode

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 ValueString hot 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)