
Compile-time plugin transforming lambda literals into expression trees (no runtime reflection), with evaluable subtrees, @Quoted parameter quoting, @Translatable call metadata, traversal and rewriting.
The Kotlin counterpart of C#'s Expression<Func<...>>. A compiler plugin (K2, IR) turns a lambda literal into code
that builds an expression tree — at compile time. There is no reflection and no bytecode parsing at runtime.
Kotlin Multiplatform: JVM, JS, Wasm, Native.
// C#: Expression<Func<int, int, int>> add = (a, b) => a + b * 2;
val add: Expr<(Int, Int) -> Int> = expr { a, b -> a + b * 2 }
add.parameters // [a: kotlin.Int, b: kotlin.Int]
add.body // BinaryExpression(PLUS, a, BinaryExpression(TIMES, b, 2))
add.toString() // { a: Int, b: Int -> a + (b * 2) }
add(1, 3) // 7 — compile() returns the original lambda, which is already compiled staticallyFor an example consumer of the trees — a LINQ-style SQL provider — see sample/README.md.
expr { } — explicit quoting, like assigning a lambda to an Expression<...> in C#:
val isAdult = expr { u: User -> u.age >= 18 && u.role == Role.USER }@Quoted — for your own APIs: a lambda passed to an annotated parameter stays a regular function,
but carries its tree with it.
class Query<T> {
fun where(@Quoted predicate: (T) -> Boolean): Query<T> {
val tree: LambdaExpression = predicate.toExpression()
// ... process the tree
}
}
users.where { it.age >= minAge } // callers write a plain lambdaThe argument must be a lambda literal. Passing a variable to expr() is a compile error; passing one to a @Quoted parameter is a warning.
Every node is a data class extending Expression and carries its static type (type):
| Node | Kotlin |
|---|---|
ConstantExpression |
42, "a", null, Role.USER, an object
|
ParameterExpression |
a lambda parameter |
CapturedExpression |
a captured variable or this (its value at the time the tree is created) |
BinaryExpression, UnaryExpression
|
+ - * / %, == != === < <= > >=, && ||, ?:, bitwise; ! - !!
|
MemberExpression |
u.name |
CallExpression |
calls; receivers are kept separately: dispatchReceiver and extensionReceiver
|
NewExpression |
a constructor call, with parameter names (parameterNames) |
ConditionalExpression |
if and when
|
TypeOperatorExpression |
is, !is, as, as?
|
StringTemplateExpression, ArrayExpression
|
"${...}", vararg
|
LambdaExpression |
the root of the tree and nested lambdas |
EvaluableExpression |
a subtree without lambda parameters (see below) |
Traversal and rewriting: children, walk(), rewrite { } — the counterpart of C#'s ExpressionVisitor.
As in C#, the lambda body must be an expression. Local vals are allowed and inlined at their uses, while var, loops and assignments cause the compile error [kexpression] Cannot convert lambda to expression tree.
The largest subtrees that don't depend on the lambda's parameters (filter.minAge + 1, repo.activeIds()) become
EvaluableExpression(original, evaluate): the structure plus an evaluator generated by the compiler. The evaluator runs the subtree's original code without reflection, on any platform.
val e = expr { u: User -> u.age > filter.minAge + 1 }
val node = (e.body as BinaryExpression).right as EvaluableExpression
node.original // filter.minAge + 1 — the structure
node.evaluate() // 19 — the value, read at the time of the callThe consumer of the tree decides what to evaluate on the client and what to process as structure, via EvaluationPolicy
(EvaluationPolicy.Always and EvaluationPolicy.Never are provided).
@Translatable functions.A meta-annotation for function annotations. Such a call is never evaluated on the client, and the annotation itself ends up in
CallExpression.annotations. The call's arguments are processed as usual.
@Translatable
annotation class DbFunction(val name: String)
@DbFunction("upper")
fun dbUpper(s: String): String = throw UnsupportedOperationException("db only")
val e = expr { u: User -> u.name == dbUpper(prefix + "x") }
val call = (e.body as BinaryExpression).right as CallExpression
call.annotation<DbFunction>()?.name // "upper"
(call.arguments.single() as EvaluableExpression).evaluate() // "abx"The plugin works on IR, which is the same for every backend. Attach it to all compilations:
dependencies {
implementation(project(":kexpression-runtime"))
"kotlinCompilerPluginClasspath"(project(":kexpression-compiler-plugin")) // JVM, JS, Wasm
"kotlinNativeCompilerPluginClasspath"(project(":kexpression-compiler-plugin")) // Native
}Platform specifics:
@Quoted lambda isn't wrapped — the tree is stored in a property of the JS function itself. On the other platforms it's a wrapper, and the number of parameters is limited to four.Int, Float and Double are the same number.| Module | Contents |
|---|---|
kexpression-runtime |
tree nodes, Expr<F>, expr {}, @Quoted, EvaluationPolicy, @Translatable, traversal and printing; KMP |
kexpression-compiler-plugin |
the IR plugin: replaces expr { } with quoteExpr(lambda, <tree>) and lambdas in @Quoted parameters with quoteFunction(lambda, <tree>)
|
kexpression-tests |
shared plugin tests (commonTest) on JVM, JS, Wasm and Native |
sample |
a SQL provider built on expression trees (JVM only) — see sample/README.md |
./gradlew :kexpression-tests:jvmTest :kexpression-tests:jsNodeTest :kexpression-tests:wasmJsNodeTest./gradlew :kexpression-tests:macosArm64Test # requires a full Xcode installThe Linux test binary (linkDebugTestLinuxArm64) can be built on macOS and run in a Linux container.
kexpression-runtime (all KMP targets) and kexpression-compiler-plugin are published with
vanniktech/gradle-maven-publish-plugin.
Versions live in gradle/libs.versions.toml, Maven coordinates in gradle.properties (buildInfo.*).
./gradlew publishToMavenLocal./gradlew publishAndReleaseToMavenCentralMaven Central credentials and the signing key are read from ~/.gradle/gradle.properties and are never stored in
this repository. Publications are signed only when a signing key is configured.
dependencies {
implementation("ru.ttraum:kexpression-runtime:0.1.0")
kotlinCompilerPluginClasspath("ru.ttraum:kexpression-compiler-plugin:0.1.0")
}var; the tree doesn't.var.The Kotlin counterpart of C#'s Expression<Func<...>>. A compiler plugin (K2, IR) turns a lambda literal into code
that builds an expression tree — at compile time. There is no reflection and no bytecode parsing at runtime.
Kotlin Multiplatform: JVM, JS, Wasm, Native.
// C#: Expression<Func<int, int, int>> add = (a, b) => a + b * 2;
val add: Expr<(Int, Int) -> Int> = expr { a, b -> a + b * 2 }
add.parameters // [a: kotlin.Int, b: kotlin.Int]
add.body // BinaryExpression(PLUS, a, BinaryExpression(TIMES, b, 2))
add.toString() // { a: Int, b: Int -> a + (b * 2) }
add(1, 3) // 7 — compile() returns the original lambda, which is already compiled staticallyFor an example consumer of the trees — a LINQ-style SQL provider — see sample/README.md.
expr { } — explicit quoting, like assigning a lambda to an Expression<...> in C#:
val isAdult = expr { u: User -> u.age >= 18 && u.role == Role.USER }@Quoted — for your own APIs: a lambda passed to an annotated parameter stays a regular function,
but carries its tree with it.
class Query<T> {
fun where(@Quoted predicate: (T) -> Boolean): Query<T> {
val tree: LambdaExpression = predicate.toExpression()
// ... process the tree
}
}
users.where { it.age >= minAge } // callers write a plain lambdaThe argument must be a lambda literal. Passing a variable to expr() is a compile error; passing one to a @Quoted parameter is a warning.
Every node is a data class extending Expression and carries its static type (type):
| Node | Kotlin |
|---|---|
ConstantExpression |
42, "a", null, Role.USER, an object
|
ParameterExpression |
a lambda parameter |
CapturedExpression |
a captured variable or this (its value at the time the tree is created) |
BinaryExpression, UnaryExpression
|
+ - * / %, == != === < <= > >=, && ||, ?:, bitwise; ! - !!
|
MemberExpression |
u.name |
CallExpression |
calls; receivers are kept separately: dispatchReceiver and extensionReceiver
|
NewExpression |
a constructor call, with parameter names (parameterNames) |
ConditionalExpression |
if and when
|
TypeOperatorExpression |
is, !is, as, as?
|
StringTemplateExpression, ArrayExpression
|
"${...}", vararg
|
LambdaExpression |
the root of the tree and nested lambdas |
EvaluableExpression |
a subtree without lambda parameters (see below) |
Traversal and rewriting: children, walk(), rewrite { } — the counterpart of C#'s ExpressionVisitor.
As in C#, the lambda body must be an expression. Local vals are allowed and inlined at their uses, while var, loops and assignments cause the compile error [kexpression] Cannot convert lambda to expression tree.
The largest subtrees that don't depend on the lambda's parameters (filter.minAge + 1, repo.activeIds()) become
EvaluableExpression(original, evaluate): the structure plus an evaluator generated by the compiler. The evaluator runs the subtree's original code without reflection, on any platform.
val e = expr { u: User -> u.age > filter.minAge + 1 }
val node = (e.body as BinaryExpression).right as EvaluableExpression
node.original // filter.minAge + 1 — the structure
node.evaluate() // 19 — the value, read at the time of the callThe consumer of the tree decides what to evaluate on the client and what to process as structure, via EvaluationPolicy
(EvaluationPolicy.Always and EvaluationPolicy.Never are provided).
@Translatable functions.A meta-annotation for function annotations. Such a call is never evaluated on the client, and the annotation itself ends up in
CallExpression.annotations. The call's arguments are processed as usual.
@Translatable
annotation class DbFunction(val name: String)
@DbFunction("upper")
fun dbUpper(s: String): String = throw UnsupportedOperationException("db only")
val e = expr { u: User -> u.name == dbUpper(prefix + "x") }
val call = (e.body as BinaryExpression).right as CallExpression
call.annotation<DbFunction>()?.name // "upper"
(call.arguments.single() as EvaluableExpression).evaluate() // "abx"The plugin works on IR, which is the same for every backend. Attach it to all compilations:
dependencies {
implementation(project(":kexpression-runtime"))
"kotlinCompilerPluginClasspath"(project(":kexpression-compiler-plugin")) // JVM, JS, Wasm
"kotlinNativeCompilerPluginClasspath"(project(":kexpression-compiler-plugin")) // Native
}Platform specifics:
@Quoted lambda isn't wrapped — the tree is stored in a property of the JS function itself. On the other platforms it's a wrapper, and the number of parameters is limited to four.Int, Float and Double are the same number.| Module | Contents |
|---|---|
kexpression-runtime |
tree nodes, Expr<F>, expr {}, @Quoted, EvaluationPolicy, @Translatable, traversal and printing; KMP |
kexpression-compiler-plugin |
the IR plugin: replaces expr { } with quoteExpr(lambda, <tree>) and lambdas in @Quoted parameters with quoteFunction(lambda, <tree>)
|
kexpression-tests |
shared plugin tests (commonTest) on JVM, JS, Wasm and Native |
sample |
a SQL provider built on expression trees (JVM only) — see sample/README.md |
./gradlew :kexpression-tests:jvmTest :kexpression-tests:jsNodeTest :kexpression-tests:wasmJsNodeTest./gradlew :kexpression-tests:macosArm64Test # requires a full Xcode installThe Linux test binary (linkDebugTestLinuxArm64) can be built on macOS and run in a Linux container.
kexpression-runtime (all KMP targets) and kexpression-compiler-plugin are published with
vanniktech/gradle-maven-publish-plugin.
Versions live in gradle/libs.versions.toml, Maven coordinates in gradle.properties (buildInfo.*).
./gradlew publishToMavenLocal./gradlew publishAndReleaseToMavenCentralMaven Central credentials and the signing key are read from ~/.gradle/gradle.properties and are never stored in
this repository. Publications are signed only when a signing key is configured.
dependencies {
implementation("ru.ttraum:kexpression-runtime:0.1.0")
kotlinCompilerPluginClasspath("ru.ttraum:kexpression-compiler-plugin:0.1.0")
}var; the tree doesn't.var.