Skip to content

About

No description, website, or topics provided.

Resources

Stars

9 stars

Watchers

0 watching

Forks

Repository files navigation

Structural typing for Kotlin

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.

Getting started

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…).

What you can do with it

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 var

Superinterfaces 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)                 // matches

A 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 }     // matches

Types 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 invariant

suspend 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)     // matches

Classes, 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 object

Call 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.

How it works

The plugin makes a matching class really implement the interface, as if you had written class Rectangular(…) : Sized. During compilation it:

  1. adds the interface as a supertype of every class in the module that matches it,
  2. marks the matching properties and functions override,
  3. explains classes that nearly match, so a near miss isn't silent:
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.

Repository layout

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

Working on this repository

Gradle needs JDK 17:

JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew build

That 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.

License

Apache License 2.0.

Status

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:

  • generic interfaces and generic members are ignored (roadmap)
  • an interface can come from another module compiled with the plugin, but the classes matching it must be compiled together; other modules can then use those classes through the interface
  • member types must be explicit
  • the plugin uses internal, experimental compiler APIs, so expect breakage on Kotlin updates
  • building it needs an EAP release of the Kotlin compiler plugin DevKit (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.

About

No description, website, or topics provided.

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages