
Enables structural typing via a compiler plugin: mark interfaces with @Structural and matching classes automatically implement them at compile time, with override insertion, clear diagnostics and IDE support.
A proof of concept that brings structural typing to Kotlin with a K2 compiler plugin: any class whose properties and functions match an interface can be used as that interface, without declaring it.
1. Apply the Gradle plugin. It adds the @Structural annotation and the compiler plugin to the module:
// settings.gradle.kts
pluginManagement {
repositories {
mavenCentral() // the plugin is on Maven Central, not the Gradle Plugin Portal
gradlePluginPortal()
}
}// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.20"
id("com.obabichev.structural") version "0.4.0-kotlin-2.4"
}Kotlin 2.4.x is required: the compiler plugin uses internal compiler APIs, which is why the version names the
Kotlin line it works with. On other versions the build fails with a clear message. The plugin is compiled against every
version of that line it supports, so one artifact covers 2.4.0, 2.4.10 and 2.4.20. Release 0.2.0 works on 2.4.20
only, whatever its notes say; see CHANGELOG.md.
2. Mark an interface and use any matching class:
import com.obabichev.structural.Structural
import kotlin.math.PI
@Structural
interface Sized {
val width: Int
val height: Int
fun area(): Int
}
// Neither of these knows about Sized
class Circle(val radius: Int) {
val width: Int get() = radius * 2
val height: Int get() = radius * 2
fun area(): Int = (PI * radius * radius).toInt()
}
enum class Paper(val width: Int, val height: Int) {
A4(210, 297);
fun area(): Int = width * height
}
fun describe(target: Sized) = "${target.width}x${target.height}=${target.area()}"
fun main() {
println(describe(Circle(3))) // 6x6=28
println(Circle(3) is Sized) // true
val shapes: List<Sized> = listOf(Circle(3), Paper.A4)
println(shapes.sumOf { it.area() }) // 62398
}Nothing is written on the classes: no annotations, no wrappers, no generated code. Member types have to be declared explicitly, and the classes matching an interface must be compiled in the same module as each other. The interface itself can live in another module, as long as that module is also built with the plugin: it publishes the interfaces it declares, and modules depending on it match their own classes against them.
3. For IntelliJ, install the IDE plugin, otherwise the editor reports Argument type mismatch errors that the build
doesn't have: download the zip from the
latest release, install it through
Settings → Plugins → ⚙ → Install Plugin from Disk… and reload the Gradle project. IntelliJ 2026.2 (262.*) only; on
other versions, turn off the registry key kotlin.k2.only.bundled.compiler.plugins.enabled instead
(Help → Find Action → Registry…).
Each case is one interface and ordinary classes. They are compiled and run by
sample/src/main/kotlin/com/example/readme/, so this list can't drift from what the plugin does.
Properties and functions. A class needs a public member Kotlin would accept as an override of each:
@Structural
interface Sized {
val width: Int
val height: Int
fun area(): Int
}A var requires a var, of exactly that type; a val may be a subtype of what the interface asks for:
@Structural interface Counter { var count: Int }
class Clicks(var count: Int) // matches
class Score(val count: Int) // doesn't: a val can't implement a varSuperinterfaces come with their members, Kotlin or Java, annotated or not:
interface Named { val name: String }
@Structural interface Labeled : Named { fun label(): String } // asks for name and label()A generic superinterface is substituted with the arguments you give it:
@Structural interface Ranked : Comparable<String> // asks for compareTo(other: String)
@Structural interface Texts : Holder<String> // val items: List<T> asks for List<String>A generic interface takes the argument the class gives it, read off the class rather than guessed:
@Structural
interface Box<T> {
val value: T
}
class IntBox(val value: Int) // a Box<Int>
class TextBox(val value: String) // a Box<String>A class whose members disagree about the parameter, or that leaves it unsaid, or that breaks its bounds, doesn't get the interface at all — the alternative would be giving a class an interface its author never meant it to have.
A member with a body is not required, so an interface can carry defaults and a class need only provide the rest:
@Structural
interface Report {
val title: String
fun headline(): String = "== $title ==" // a class doesn't have to provide this
}
class Page(val title: String) // matchesA requirement may be generic itself, matched by a member of the same shape whatever its parameter is called:
@Structural interface Mapper { fun <T> map(value: T): T }
class Identity { fun <R> map(value: R): R = value } // matchesTypes with arguments follow the variance they declare, as a hand-written override would:
@Structural interface Shelf { val items: List<CharSequence> }
class Books(val items: List<String>) // matches: List<out E> is covariant
class Shopping(val items: MutableList<String>) // doesn't: MutableList<E> is invariantsuspend and vararg are part of the shape, and have to agree on both sides:
@Structural
interface Loader {
suspend fun load(): String
fun merge(vararg others: String): String
}A class may declare the interface itself; the plugin leaves it alone, and a missing override stays an ordinary
compiler error.
Superinterfaces may be Java interfaces, annotated or not — their members are required the same way.
A requirement may name a type nested in the interface, which no import mentions:
@Structural
interface Shape {
val kind: Kind
enum class Kind { ROUND, FLAT }
}
class Disc(val kind: Shape.Kind) // matchesClasses, objects, enum classes and nested classes all qualify, as do members inherited from a superclass:
class Rectangular(val width: Int, val height: Int) { fun area(): Int = width * height }
object Square { val width: Int = 1; val height: Int = 1; fun area(): Int = 1 }
enum class Paper(val width: Int, val height: Int) { A4(210, 297); fun area(): Int = width * height }Member types have to be written out: supertypes are decided before inferred types are known, so fun area() = width * height would not match. The plugin warns when that is the only thing in the way.
The interface may live in another module built with the plugin — your classes don't have to be next to it:
// module :core
@Structural interface Sized { val width: Int; val height: Int }
// module :app, which depends on :core and mentions Sized nowhere
class Photo(val width: Int, val height: Int)What that gives you, because the class really implements the interface:
area(Paper.A4) // 62370 — an enum entry where Sized is expected
listOf<Sized>(Rectangular(1, 1), Paper.A4) // a List<Sized> of unrelated classes
Rectangular(1, 1) as Any is Sized // true — a real is-check, same objectCall sites need nothing special either: Sized?, List<Sized>, vararg Sized, default arguments, generics and
extensions on the interface all work, because the class implements it like any other.
And nothing fails silently. When a class nearly matches, the compiler says why, where the class is used:
e: 'Panel' does not implement @Structural interface 'com.example.Sized':
height: is internal, must be public
When an interface is one the plugin can't use at all, it says so on the interface, which is where you can fix it:
w: @Structural interface 'Box' is ignored, so no class will implement it: it has type parameters,
so a class can't be told which arguments it gets.
What it won't do: member types have to be written out rather than
inferred, and a class from a dependency can't gain an interface — its class file is fixed.
docs/known-issues.md has the full list.
The plugin makes a matching class really implement the interface, as if you had written
class Rectangular(…) : Sized. During compilation it:
override,e: 'Panel' does not implement @Structural interface 'com.example.Sized':
height: is internal, must be public
The compiler does the rest: override checks, bridge methods, bytecode, incremental compilation. Because the class really
implements the interface, identity is preserved (===), is checks work, and List<Rectangular> is a List<Sized>.
A class matches when, for every abstract member of the interface and its superinterfaces, it has a public member that
Kotlin would accept as an override: a val of a subtype, a var of exactly the same type, or a function with the same
signature. docs/design.md has the exact rules.
| Module | Contents |
|---|---|
structural-annotations |
The @Structural annotation |
structural-compiler-plugin |
The K2 compiler plugin, built against every supported Kotlin version |
structural-compiler-plugin-tests |
Its tests: they compile snippets with the plugin and run the result |
structural-gradle-plugin |
Gradle plugin that applies both to a module |
structural-intellij-plugin |
IntelliJ plugin so the IDE analyzes code the same way as the build, and the tests that check it does |
sample |
A Gradle module using the plugin; its tests show every supported case |
sample-library |
A module publishing a @Structural interface that sample matches from its own classes |
| Document | Contents |
|---|---|
CHANGELOG.md |
What changed in each version |
docs/design.md |
How it works: the matching rules, the compiler phases, and why |
docs/known-issues.md |
Current limitations, with what has been verified |
docs/roadmap.md |
Planned work, starting with generics |
docs/publishing.md |
How the artifacts are published |
docs/multiple-kotlin-versions.md |
How one artifact supports every Kotlin version in the line |
Gradle needs JDK 17:
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew buildThat compiles the plugin against each supported Kotlin version and runs every test, including the ones that analyze code the way IntelliJ does:
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew :structural-intellij-plugin:test \
"-Pstructural.ideaPath=/path/to/IntelliJ IDEA.app"The sample's tests are the best place to see what works: one file per feature under
sample/src/test/kotlin/com/example/. Modules inside this repository wire the plugin up directly instead of applying
the published one, as sample/build.gradle.kts shows:
dependencies {
implementation(project(":structural-annotations"))
kotlinCompilerPluginClasspath(project(":structural-compiler-plugin"))
}To build the IntelliJ plugin zip yourself:
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew :structural-intellij-plugin:buildPlugin \
"-Pstructural.ideaPath=/Applications/IntelliJ IDEA.app"Without -Pstructural.ideaPath, Gradle downloads IntelliJ IDEA 2026.2 to build against.
A proof of concept: 109 tests cover the supported cases, and the whole build passes without compiler warnings. It is published for trying out, not for production use. The main limitations:
docs/known-issues.md)The first version of this proof of concept generated overloads and adapters with KSP. KSP can't see call sites or change
existing classes, so it produced a lot of code and still couldn't support is checks, identity or collections; the
compiler plugin replaced it. The git history keeps both.
A proof of concept that brings structural typing to Kotlin with a K2 compiler plugin: any class whose properties and functions match an interface can be used as that interface, without declaring it.
1. Apply the Gradle plugin. It adds the @Structural annotation and the compiler plugin to the module:
// settings.gradle.kts
pluginManagement {
repositories {
mavenCentral() // the plugin is on Maven Central, not the Gradle Plugin Portal
gradlePluginPortal()
}
}// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.20"
id("com.obabichev.structural") version "0.4.0-kotlin-2.4"
}Kotlin 2.4.x is required: the compiler plugin uses internal compiler APIs, which is why the version names the
Kotlin line it works with. On other versions the build fails with a clear message. The plugin is compiled against every
version of that line it supports, so one artifact covers 2.4.0, 2.4.10 and 2.4.20. Release 0.2.0 works on 2.4.20
only, whatever its notes say; see CHANGELOG.md.
2. Mark an interface and use any matching class:
import com.obabichev.structural.Structural
import kotlin.math.PI
@Structural
interface Sized {
val width: Int
val height: Int
fun area(): Int
}
// Neither of these knows about Sized
class Circle(val radius: Int) {
val width: Int get() = radius * 2
val height: Int get() = radius * 2
fun area(): Int = (PI * radius * radius).toInt()
}
enum class Paper(val width: Int, val height: Int) {
A4(210, 297);
fun area(): Int = width * height
}
fun describe(target: Sized) = "${target.width}x${target.height}=${target.area()}"
fun main() {
println(describe(Circle(3))) // 6x6=28
println(Circle(3) is Sized) // true
val shapes: List<Sized> = listOf(Circle(3), Paper.A4)
println(shapes.sumOf { it.area() }) // 62398
}Nothing is written on the classes: no annotations, no wrappers, no generated code. Member types have to be declared explicitly, and the classes matching an interface must be compiled in the same module as each other. The interface itself can live in another module, as long as that module is also built with the plugin: it publishes the interfaces it declares, and modules depending on it match their own classes against them.
3. For IntelliJ, install the IDE plugin, otherwise the editor reports Argument type mismatch errors that the build
doesn't have: download the zip from the
latest release, install it through
Settings → Plugins → ⚙ → Install Plugin from Disk… and reload the Gradle project. IntelliJ 2026.2 (262.*) only; on
other versions, turn off the registry key kotlin.k2.only.bundled.compiler.plugins.enabled instead
(Help → Find Action → Registry…).
Each case is one interface and ordinary classes. They are compiled and run by
sample/src/main/kotlin/com/example/readme/, so this list can't drift from what the plugin does.
Properties and functions. A class needs a public member Kotlin would accept as an override of each:
@Structural
interface Sized {
val width: Int
val height: Int
fun area(): Int
}A var requires a var, of exactly that type; a val may be a subtype of what the interface asks for:
@Structural interface Counter { var count: Int }
class Clicks(var count: Int) // matches
class Score(val count: Int) // doesn't: a val can't implement a varSuperinterfaces come with their members, Kotlin or Java, annotated or not:
interface Named { val name: String }
@Structural interface Labeled : Named { fun label(): String } // asks for name and label()A generic superinterface is substituted with the arguments you give it:
@Structural interface Ranked : Comparable<String> // asks for compareTo(other: String)
@Structural interface Texts : Holder<String> // val items: List<T> asks for List<String>A generic interface takes the argument the class gives it, read off the class rather than guessed:
@Structural
interface Box<T> {
val value: T
}
class IntBox(val value: Int) // a Box<Int>
class TextBox(val value: String) // a Box<String>A class whose members disagree about the parameter, or that leaves it unsaid, or that breaks its bounds, doesn't get the interface at all — the alternative would be giving a class an interface its author never meant it to have.
A member with a body is not required, so an interface can carry defaults and a class need only provide the rest:
@Structural
interface Report {
val title: String
fun headline(): String = "== $title ==" // a class doesn't have to provide this
}
class Page(val title: String) // matchesA requirement may be generic itself, matched by a member of the same shape whatever its parameter is called:
@Structural interface Mapper { fun <T> map(value: T): T }
class Identity { fun <R> map(value: R): R = value } // matchesTypes with arguments follow the variance they declare, as a hand-written override would:
@Structural interface Shelf { val items: List<CharSequence> }
class Books(val items: List<String>) // matches: List<out E> is covariant
class Shopping(val items: MutableList<String>) // doesn't: MutableList<E> is invariantsuspend and vararg are part of the shape, and have to agree on both sides:
@Structural
interface Loader {
suspend fun load(): String
fun merge(vararg others: String): String
}A class may declare the interface itself; the plugin leaves it alone, and a missing override stays an ordinary
compiler error.
Superinterfaces may be Java interfaces, annotated or not — their members are required the same way.
A requirement may name a type nested in the interface, which no import mentions:
@Structural
interface Shape {
val kind: Kind
enum class Kind { ROUND, FLAT }
}
class Disc(val kind: Shape.Kind) // matchesClasses, objects, enum classes and nested classes all qualify, as do members inherited from a superclass:
class Rectangular(val width: Int, val height: Int) { fun area(): Int = width * height }
object Square { val width: Int = 1; val height: Int = 1; fun area(): Int = 1 }
enum class Paper(val width: Int, val height: Int) { A4(210, 297); fun area(): Int = width * height }Member types have to be written out: supertypes are decided before inferred types are known, so fun area() = width * height would not match. The plugin warns when that is the only thing in the way.
The interface may live in another module built with the plugin — your classes don't have to be next to it:
// module :core
@Structural interface Sized { val width: Int; val height: Int }
// module :app, which depends on :core and mentions Sized nowhere
class Photo(val width: Int, val height: Int)What that gives you, because the class really implements the interface:
area(Paper.A4) // 62370 — an enum entry where Sized is expected
listOf<Sized>(Rectangular(1, 1), Paper.A4) // a List<Sized> of unrelated classes
Rectangular(1, 1) as Any is Sized // true — a real is-check, same objectCall sites need nothing special either: Sized?, List<Sized>, vararg Sized, default arguments, generics and
extensions on the interface all work, because the class implements it like any other.
And nothing fails silently. When a class nearly matches, the compiler says why, where the class is used:
e: 'Panel' does not implement @Structural interface 'com.example.Sized':
height: is internal, must be public
When an interface is one the plugin can't use at all, it says so on the interface, which is where you can fix it:
w: @Structural interface 'Box' is ignored, so no class will implement it: it has type parameters,
so a class can't be told which arguments it gets.
What it won't do: member types have to be written out rather than
inferred, and a class from a dependency can't gain an interface — its class file is fixed.
docs/known-issues.md has the full list.
The plugin makes a matching class really implement the interface, as if you had written
class Rectangular(…) : Sized. During compilation it:
override,e: 'Panel' does not implement @Structural interface 'com.example.Sized':
height: is internal, must be public
The compiler does the rest: override checks, bridge methods, bytecode, incremental compilation. Because the class really
implements the interface, identity is preserved (===), is checks work, and List<Rectangular> is a List<Sized>.
A class matches when, for every abstract member of the interface and its superinterfaces, it has a public member that
Kotlin would accept as an override: a val of a subtype, a var of exactly the same type, or a function with the same
signature. docs/design.md has the exact rules.
| Module | Contents |
|---|---|
structural-annotations |
The @Structural annotation |
structural-compiler-plugin |
The K2 compiler plugin, built against every supported Kotlin version |
structural-compiler-plugin-tests |
Its tests: they compile snippets with the plugin and run the result |
structural-gradle-plugin |
Gradle plugin that applies both to a module |
structural-intellij-plugin |
IntelliJ plugin so the IDE analyzes code the same way as the build, and the tests that check it does |
sample |
A Gradle module using the plugin; its tests show every supported case |
sample-library |
A module publishing a @Structural interface that sample matches from its own classes |
| Document | Contents |
|---|---|
CHANGELOG.md |
What changed in each version |
docs/design.md |
How it works: the matching rules, the compiler phases, and why |
docs/known-issues.md |
Current limitations, with what has been verified |
docs/roadmap.md |
Planned work, starting with generics |
docs/publishing.md |
How the artifacts are published |
docs/multiple-kotlin-versions.md |
How one artifact supports every Kotlin version in the line |
Gradle needs JDK 17:
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew buildThat compiles the plugin against each supported Kotlin version and runs every test, including the ones that analyze code the way IntelliJ does:
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew :structural-intellij-plugin:test \
"-Pstructural.ideaPath=/path/to/IntelliJ IDEA.app"The sample's tests are the best place to see what works: one file per feature under
sample/src/test/kotlin/com/example/. Modules inside this repository wire the plugin up directly instead of applying
the published one, as sample/build.gradle.kts shows:
dependencies {
implementation(project(":structural-annotations"))
kotlinCompilerPluginClasspath(project(":structural-compiler-plugin"))
}To build the IntelliJ plugin zip yourself:
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew :structural-intellij-plugin:buildPlugin \
"-Pstructural.ideaPath=/Applications/IntelliJ IDEA.app"Without -Pstructural.ideaPath, Gradle downloads IntelliJ IDEA 2026.2 to build against.
A proof of concept: 109 tests cover the supported cases, and the whole build passes without compiler warnings. It is published for trying out, not for production use. The main limitations:
docs/known-issues.md)The first version of this proof of concept generated overloads and adapters with KSP. KSP can't see call sites or change
existing classes, so it produced a lot of code and still couldn't support is checks, identity or collections; the
compiler plugin replaced it. The git history keeps both.