Interfaces, abstract classes, and sealed classes
Kotlin's three flavours of inheritance each get a distinct C# shape: interface becomes an I-prefixed C# interface with default methods delegating back to Kotlin, abstract class becomes a C# abstract class whose subclasses share one inherited _handle, and sealed class becomes an abstract class with each subtype reconstructed through a generated discriminator.
Kotlin | C# | Notes |
|---|---|---|
|
| default methods delegate to Kotlin |
|
|
|
|
| each subtype its own class, nested inside the base or declared beside it, reconstructed through a generated |
eligible |
| same shape as |
ineligible |
| stays on the ordinary interface route; every member typed with it is skipped |
Interfaces
A default method or property delegates back to Kotlin, so a class that never overrides it still gets a working implementation:
Cat : IPet only declares Name and Speak(); Greet() still works, reached through the interface's own default body:
Interface-typed return values
A function or property whose declared type is an interface (fun closestFriend(): Pet) returns IFoo/IFoo?, backed by a generated wrapper that dispatches every member back into Kotlin. This works even when the real Kotlin object is anonymous (object : Pet { ... }); the consumer only ever sees the interface, never a concrete type:
Each read returns a fresh wrapper. Reading cat.Friend twice returns two different C# objects over the same Kotlin instance, and each disposes independently, so dispose every interface-typed value you receive.
Defaulted interface members on implementing classes
A class that implements an interface without overriding one of its defaulted members still carries that member in C#, reached through the Kotlin default body rather than generated separately:
Widening a read-only property to var
Kotlin lets a subclass widen an inherited val to var. Whether the C# setter survives the crossing depends on what it widens:
Widening an interface's
valalways compiles: the implementing class's property rendersvirtual, notoverride, and avirtualdeclaration is free to add a setter the interface never declared.Widening an exported base class's own
valdoes not: the base already renders a get-onlyvirtualproperty, so a derived{ get; set; }overrideof it would fail to compile (CS0546). The setter is dropped and the property stays read-only in C#; the build emits a warning explaining why.
Cat.Vibe compiles as a get-only override string Vibe; there is no generated Vibe setter.
Implementing a Kotlin interface in C#
A plain C# class implementing IFoo, with no Kotlin _handle, can be passed anywhere IFoo is expected: a parameter, a property setter, or an extension receiver (including an extension property's receiver). Kotlin calls back into it through generated function pointers, so a Kotlin-side call actually reaches your C# implementation:
Only a limited set of member shapes can cross this bridge: val getters, and methods of arity 0-2 returning Unit, a primitive, Boolean, an enum, or String/String?. An interface with a var property, an object- or collection-typed member, a suspend member, or a generic member has no bridge at all: passing an implementation of it throws NotSupportedException, naming the C# type, the first time it crosses, not at build time.
Lifetime and identity
The bridge object's release is GC-timed, not deterministic: Kotlin frees it on a later garbage-collection round, not the moment the C# reference goes out of scope. There is no IDisposable-style prompt release for a C#-implemented interface.
Reading a stored C#-implemented object back from Kotlin, whether through a property, a suspend fun completion, or a Flow<T> element, resolves to the original C# instance, not a fresh wrapper:
This identity match is C#-side only. Passing the same C# object into Kotlin twice builds two separate bridge objects, so Kotlin-side === does not treat them as equal, the same way identity is not preserved across two reads of a Kotlin-backed interface property either (see Interface-typed return values above).
An interface reachable only at a parameter position
An interface that is never returned anywhere, only ever taken as a parameter, a property setter, or an extension property receiver, still gets the full bridge treatment described above:
Nested interfaces
An interface nested inside a non-generic, non-inner class or object is declared as a real nested C# interface, Outer.IListener, with its return-position wrapper nested beside it (Outer.Listener); see Classes and objects: Nested types. A nested interface under a still-deferred owner shape, an inner class, a generic class, or an enum class, has no C# spelling: it is skipped, and any member typed with it is skipped too, each named on a build warning rather than silently dropped.
Abstract classes
abstract class becomes a C# abstract class. Every subclass shares one inherited _handle field rather than redeclaring it:
Cat : Animal only declares what it overrides (Speak(), Dispose()); it never redeclares _handle.
An open val/open var/open fun the base declares itself renders public virtual, so a subclass override of it compiles; a member that stays the Kotlin default (final) carries no modifier and cannot be overridden in C# either. Writing a member through the base static type still reaches the subclass's own Kotlin dispatch, not just the C# facade:
A base class's own abstract fun
An exported base class's own unimplemented abstract val/abstract var/abstract fun, one declared with no body, renders public abstract in C# too, so a subclass override compiles instead of failing to build:
truck.Describe() still calls Kotlin's own honk() dispatch, so reading it through a Vehicle-typed reference proves the override reaches the real Kotlin object, not just the C# wrapper.
The same treatment applies when the abstract member is inherited from an interface and never implemented on the base itself, even when that interface is never declared in C# at all (an unexported interface): the abstract class still declares the inherited member public abstract, so a further C# subclass compiles.
The same also applies when the abstract member is inherited from an unexported abstract base class instead of an interface. SKIPPED_UNEXPORTED_SUPERTYPE already drops that base from the C# class list and re-homes its bridgeable members onto the exported subclass; an abstract member it never implements now renders public abstract on that subclass too, so a further C# or Kotlin override compiles instead of failing to build:
A member the planner declines to plan, for example a generic interface default the type mapper cannot spell abstractly, or one whose own type has no C# declaration (a nested class never exported), is dropped from the generated class instead of rendered abstract, named on a build warning; an uncompilable abstract member would break every further subclass.
A class-typed abstract member
A parameter or return typed with another declared class renders fully qualified, never a bare name: the generated file carries only the System usings, so an unqualified reference would resolve to nothing outside the declaring class's own namespace.
A type with no C# declaration at all, an unexported or nested-under-an-unexported-owner class, is dropped from the abstract declaration instead of spelled, named on a build warning.
Sealed classes and interfaces
sealed class becomes a C# abstract class, with each subtype its own class and a generated FromHandle reading a type tag off the native handle to reconstruct the right one:
A subclass declared inside the sealed base stays nested (Observation.Alive); one declared beside the base in the same file is still discriminated by FromHandle, but is declared at namespace level instead (public sealed class Label : FlatShape, not FlatShape.Label): match on the bare name in a switch, not the nested spelling.
A class-kind arm whose public constructor parameters are all bridgeable exports that constructor too, spelled exactly like an ordinary class with the same parameters and chaining into the inherited handle:
new Nap.Deep(minutes: 12) then works like any other constructed object, at an arm-typed or a base-typed parameter. An object/data object arm, like Zoomies, is unchanged: Kotlin gives it no public constructor to export, so it stays reachable only from Kotlin. An arm whose every constructor is refused, for example one whose sole parameter is an opt-in-marked type, keeps only its internal handle constructor and warns WARNING_NO_PUBLIC_CONSTRUCTOR, the same as a non-subclass class with no reachable constructor.
A data class/data object arm gets Equals/GetHashCode/ToString the same as any data class; compare two reads of the same singleton arm with Equals, not ReferenceEquals.
Sealed interfaces
A sealed interface maps the same way exactly when it is eligible: no type parameters, and every subclass is a class/object, nested in the interface or declared beside it, with no other superclass, no sub-interface, no second sealed-interface parent, and no enum class arm. An eligible sealed interface renders as an abstract class, exactly like sealed class above; no IFoo interface is ever declared for it:
An ineligible sealed interface, one whose arm extends another class, implements a second sealed interface, or is declared as an enum class, stays on the ordinary interface route instead: it keeps its plain IFoo declaration, and every function, property, or parameter typed with it is skipped, named on a build warning. There is no partial binding for an ineligible sealed interface: make every arm a plain class/object with no other superclass, or use a sealed class instead.
Sealed types as property types
A property whose type is a sealed class or an eligible sealed interface binds as the sealed base, materialized through FromHandle (see Monitor.Current above). This covers the bare type, a nullable sealed type, and a List/Map/Set component, read-only or var; a mutable collection of a sealed type gets a real setter, not just a getter. Because the getter always hands back a genuine subclass instance, pattern matching works immediately with no extra cast:
An extension function's or property's receiver typed as a sealed base binds too; see Extensions: Sealed receivers. A value class whose underlying type is sealed binds the same way; see Value classes: Over a sealed type.
A sealed type at a parameter position
A sealed type at a parameter position, bare, nullable, or a collection component, including a constructor parameter, binds as an ordinary handle argument:
A consumer cannot construct a sealed subclass directly, so every argument here comes from an existing getter or return value, never new.
A nested sealed subclass at a member position
A property, method return, or parameter typed with a sealed subclass that is nested inside its base keeps the base in its C# name, NestedShape.Circle, not the bare Circle:
A suspend fun returning a nested arm spells the same way: Task<Job.Running>, not Task<Running>.
Members of a sealed arm
A public method or property a sealed subclass itself declares, including its own override, binds the same as it would on an ordinary class: overloads, default-argument omitting overloads, nullable types, collections, and Duration/Uuid /value-class/interface shapes all work on an arm too, exported under the arm's own name. This is declared-only: an arm never re-exports a member it merely inherits unchanged.
The sealed base's own declared abstract/open member is a different carrier: it renders public virtual on the C# base itself (never abstract), so a consumer holding the sealed base can read it without pattern-matching to an arm first:
A default parameter that lives on an interface the sealed base overrides counts too, even though Kotlin forbids the base's own override from restating it: the base still gets the omitting overload, and every arm inherits it through C# inheritance.
Suspend methods on a sealed arm
A suspend fun an arm declares binds like an ordinary class's suspend method, as Task<T> XxxAsync(...) under the arm's own name. An arm that declares a suspend member also implements IAsyncDisposable, draining its own coroutine scope before disposing the handle; an arm with no suspend member stays IDisposable only. Because this is per-arm, a consumer holding the sealed base has to pattern-match to the concrete arm before await using/DisposeAsync():
The sealed base's own open suspend fun has no carrier on the base at all, unlike an ordinary open/abstract member: it is absent everywhere unless an arm overrides it and declares its own.
A suspend fun returning the sealed base
A suspend fun returning the sealed base itself, rather than a specific arm, binds as Task<Job> (or Task<Job?>) and completes through the same FromHandle discriminator a synchronous return uses:
Flow and StateFlow members on a sealed arm
A Flow<T>/StateFlow<T> an arm declares, at a property or a method return, binds the same shape an ordinary class's flow member does, see Coroutines and Flow, under the arm's own name. As with suspend, only an arm that declares a flow member gets IAsyncDisposable; the sealed base's own open flow member has no base carrier. An arm that already has a suspend member and gains a flow member too shares the same scope and DisposeAsync, not two.
Lambda parameters on a sealed arm
A method taking a function parameter ((String) -> String) an arm declares binds the same shape an ordinary class's callback method does, see Lambdas and callbacks, under the arm's own name:
Stored-callback and interface-bridge pairs on a sealed arm
An addX/removeX stored-callback or interface-bridge pair an arm declares returns the same IDisposable subscription an ordinary class's pair does, see Lambdas and callbacks, under the arm's own name:
A data object arm's subscriber list is process-wide, since there is only one instance, so a subscription on it must be disposed by whoever added it and can outlive any single caller.
An override of a base member the base declined to plan
If the base's own member is behind an opt-in marker and its plan skips it structurally, the base declares nothing for it at all, not even virtual. An arm that overrides it anyway still binds, but its override renders as a plain public method rather than override, since there is nothing on the base to relate to.
Open arms and further nesting
A sealed arm declared open renders public class instead of public sealed class, with its own open members virtual, so a further Kotlin subclass of it compiles. That further subclass takes the ordinary class route, spelled through the arm's nested name (Roost.HighPerch extends Roost.Perch). The FromHandle discriminator only distinguishes direct arms: a handle for a further subclass still reconstructs as the arm itself (Roost.Perch), never the deeper subclass. The underlying Kotlin object is correct and dispatch through the wrapper still reaches the subclass's own overrides, but a consumer cannot pattern-match past the arm.
A sealed base, a sealed arm, and any interface owner can nest their own plain class/object/interface/enum class/value class, declared beside the owner's other members (Purr.Detail, Purr.On.Trace, Beam.Lens); see Classes and objects: Nested types. Only an inner class, a generic class, or an enum class owner still cannot host a nested declaration.
Limitations
A C#-implemented interface's bridge only supports
valgetters and arity 0-2 methods returningUnit, a primitive,Boolean, an enum, orString/String?. Anything else (avarproperty, an object- or collection-typed member,suspend, generics) throwsNotSupportedExceptionthe first time an implementation is passed, naming the C# type, with nothing at build time naming which member disqualified it.A C#-implemented object's bridge is released on Kotlin's next garbage-collection round, not deterministically; there is no
IDisposable-style prompt release for it.An interface member whose own return type is another interface or a class handle (chained resolution) is not supported. Suspend interface members,
Flow/StateFlow-valued interface members, and generic interface type parameters at a return position are not supported either.Object identity is not preserved across two reads of a Kotlin-backed interface property: each read is a distinct C# wrapper over the same Kotlin object. A stored C#-implemented object is the exception: it always resolves back to the original instance.
An ineligible sealed interface, an arm with another superclass, an
enum classarm, or an arm implementing a second sealed interface, has no binding at all: every function, property, or parameter typed with it is skipped.An eligible sealed interface arm's own extra interfaces (
class Odd : Kind, CharSequence) are dropped silently from the generated class.An
objectarm, or aclass-kind arm whose every constructor is refused, has only theinternalhandle constructor; anint-convertible literal implicitly converts toIntPtr(ninton .NET 7+) there, sonew Base.Arm(9)compiles and access-violates at runtime instead of failing to build. Aclass-kind arm with bridgeable constructor parameters exports a real public constructor instead; see Sealed classes and interfaces.interface Derived : Basedoes not carryBase's members ontoIDerived; avarinterface property always renders{ get; }only on the generated interface, even when an implementing class's own property has a setter.The sealed base's own
open suspend funand anyFlow/StateFlowmember it declares have no carrier on the C# base at all: only an arm that itself declares one binds, and only that arm gainsIAsyncDisposable. A consumer holding the sealed base must pattern-match to the concrete arm before an async call orawait using.A generic method or a
suspendlambda parameter on a sealed arm has no binding, the same as on an ordinary class.