
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.
Apply the Gradle plugin next to a Kotlin plugin (multiplatform, jvm or android):
// settings.gradle.kts
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}// build.gradle.kts
plugins {
kotlin("multiplatform") version "2.4.20"
id("ru.ttraum.kexpression") version "0.1.0"
}The plugin attaches kexpression-compiler-plugin to every Kotlin compilation and adds kexpression-runtime of the same version
to commonMain (or implementation for single-platform projects). To declare the runtime yourself:
kexpression {
addRuntimeDependency = false
}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. The Gradle plugin attaches it to all compilations (JVM, JS, Wasm, Native) — see Installation.
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-gradle-plugin |
the Gradle plugin ru.ttraum.kexpression: attaches the compiler plugin and the runtime; an included build |
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.
The plugin is built for the Kotlin version pinned in gradle/libs.versions.toml. Any other release can be tried with
-PkotlinVersion — it replaces Kotlin in the whole build, the Gradle plugin included:
./gradlew -PkotlinVersion=2.5.0-Beta2 :kexpression-tests:jvmTest :sample:testThis rebuilds the compiler plugin too, so it answers "would a plugin release for that Kotlin work" (source
compatibility). Whether the plugin built with the pinned Kotlin — the one users get — runs on another compiler is a
separate question (binary compatibility): the compiler's API can change in ways that still compile but break an
already built plugin. -PcompilerPluginJar uses a prebuilt plugin instead of the kexpression-compiler-plugin module:
./gradlew :kexpression-compiler-plugin:jar./gradlew -PkotlinVersion=2.5.0-Beta2 -PcompilerPluginJar=kexpression-compiler-plugin/build/libs/kexpression-compiler-plugin-0.1.0.jar :kexpression-tests:jvmTest :sample:testCI (kotlin-compat.yml) runs both checks on the latest patch of the pinned minor release, the latest stable release and the latest Beta/RC if it is newer, plus the regular tests on the pinned version — on every push and every Monday. Only the pinned version has to pass; the others are a compatibility report. A green binary run for a version means the published plugin can be used with it. Extra versions can be passed when the workflow is started by hand.
kexpression-runtime (all KMP targets), kexpression-compiler-plugin and kexpression-gradle-plugin (with its plugin marker) 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.
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.
Apply the Gradle plugin next to a Kotlin plugin (multiplatform, jvm or android):
// settings.gradle.kts
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}// build.gradle.kts
plugins {
kotlin("multiplatform") version "2.4.20"
id("ru.ttraum.kexpression") version "0.1.0"
}The plugin attaches kexpression-compiler-plugin to every Kotlin compilation and adds kexpression-runtime of the same version
to commonMain (or implementation for single-platform projects). To declare the runtime yourself:
kexpression {
addRuntimeDependency = false
}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. The Gradle plugin attaches it to all compilations (JVM, JS, Wasm, Native) — see Installation.
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-gradle-plugin |
the Gradle plugin ru.ttraum.kexpression: attaches the compiler plugin and the runtime; an included build |
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.
The plugin is built for the Kotlin version pinned in gradle/libs.versions.toml. Any other release can be tried with
-PkotlinVersion — it replaces Kotlin in the whole build, the Gradle plugin included:
./gradlew -PkotlinVersion=2.5.0-Beta2 :kexpression-tests:jvmTest :sample:testThis rebuilds the compiler plugin too, so it answers "would a plugin release for that Kotlin work" (source
compatibility). Whether the plugin built with the pinned Kotlin — the one users get — runs on another compiler is a
separate question (binary compatibility): the compiler's API can change in ways that still compile but break an
already built plugin. -PcompilerPluginJar uses a prebuilt plugin instead of the kexpression-compiler-plugin module:
./gradlew :kexpression-compiler-plugin:jar./gradlew -PkotlinVersion=2.5.0-Beta2 -PcompilerPluginJar=kexpression-compiler-plugin/build/libs/kexpression-compiler-plugin-0.1.0.jar :kexpression-tests:jvmTest :sample:testCI (kotlin-compat.yml) runs both checks on the latest patch of the pinned minor release, the latest stable release and the latest Beta/RC if it is newer, plus the regular tests on the pinned version — on every push and every Monday. Only the pinned version has to pass; the others are a compatibility report. A green binary run for a version means the published plugin can be used with it. Extra versions can be passed when the workflow is started by hand.
kexpression-runtime (all KMP targets), kexpression-compiler-plugin and kexpression-gradle-plugin (with its plugin marker) 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.
var; the tree doesn't.var.