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).
- What doesn't become an evaluator: constants and captured variables (they are already values), nested lambdas, and calls to
@Translatablefunctions. - Side effects: an evaluator runs the original code every time it's called. Keep that in mind if the code has side effects.
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:
- JS: a class can't implement a function type, so a
@Quotedlambda 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. - Literal printing relies on the node's static type, not on the value: on JS,
Int,FloatandDoubleare 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")
}- Capture by value. Captured variables are recorded when the tree is created. A regular lambda sees later changes to a
var; the tree doesn't. - Expressions only. Trees have no statements: no assignments, loops or
var. - Argument names. They aren't kept for function calls; they are kept for constructor calls.
- The plugin is built for Kotlin 2.4.20. The compiler's IR API is unstable, so your project must use the same Kotlin version.