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; a super-interface's members are inherited, not redeclared; |
|
|
|
|
| each subtype its own class, nested inside the base or declared beside it, reconstructed through a generated |
eligible |
| same shape as |
|
| binds at every sealed position; a returned value is the concrete arm; see A sealed interface whose arms extend a class |
any other |
| stays on the ordinary interface route; every member typed with it is skipped, named |
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.
A List, Set or Map of an interface (List<Pet>) binds too, as IReadOnlyList<IPet> and its kin; see Collections: Interfaces as collection components.
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:
If the implementing class is itself open (or abstract), its inherited default renders public virtual instead, so a further Kotlin subclass can override it:
If the interface is not exported, a generic default (or any default no route binds) is not carried and is named once on each exported class that inherits it. Its Flow-return and lambda-parameter defaults still bind on each implementing class.
The same applies to an open sealed arm inheriting a default from one of its own interfaces: the arm renders it virtual too, so a further Kotlin subclass of the arm compiles. A final owner's inherited default stays non-virtual either way, since Kotlin agrees it cannot be overridden.
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
val, or avarwhose own setter is narrower than public (private set,protected set,internal set, see Classes and objects: A setter narrower than public binds get-only), does not: the base already renders a get-onlyvirtualproperty, so a derived{ get; set; }overrideof it would fail to compile (CS0546). The public property stays get-only and the build emits a warning explaining why. If the same property also widens an exported interface'svar(below), the setter is not lost: it is still reachable, just not through the public property.
Cat.Vibe compiles as a get-only override string Vibe; there is no generated Vibe setter.
An interface's own var property
A var on an exported interface renders { get; set; } on the generated interface, and any class that implements it, Kotlin- or C#-side, must supply a setter:
A setter that cannot plan for a type-level reason stays { get; } on the interface too, named in the build log and on the member itself, whether or not any class ever implements the interface. IllegalStateException? above is such a type: C# exceptions reach Kotlin as a RuntimeException, so a narrower declared type cannot hold one (see Passing an exception to Kotlin).
One shape needs a second render, because it would otherwise fail to compile: a class that overrides both an exported base class's open val and an exported interface's var with a single Kotlin override var. The public property can't gain a setter (the widening rule above still applies), so C# can't satisfy ITally.Count { get; set; } through it; the setter is instead implemented explicitly, reachable only by declaring the reference as the interface:
An ordinary implementer with no read-only base in the way (class Abacus : Tally) needs none of this: its own public setter satisfies ITally directly, the same as any other interface member. A sealed arm gets the identical explicit-member treatment whenever its own read-only sealed base is in the way; see A sealed arm's own interfaces below. Consequence for your own code: a C# class or sealed arm implementing a var-bearing interface must declare every setter the interface now asks for; one that only declared a getter before this render shipped no longer compiles.
Async members on an interface
A suspend, Flow<T>, or StateFlow<T> member on an interface, abstract or defaulted, is declared on the generated I<Name> itself, with the same signature the class route uses, so a caller holding only the interface-typed reference can still reach it. The interface becomes IAsyncDisposable, draining whichever object is behind it:
A generic implementer, which ADR-147 refuses these members on directly, gets them as explicit interface implementations instead: they dispatch correctly, but only through an IFeed-typed reference, not through the concrete generic type:
An interface nested in a class or object gets the same treatment: its async members are declared on the nested Outer.I<Name>, and a generic implementer still reaches them through that interface reference.
An async member inherited from a super-interface is callable through the derived interface, even when nothing in your Kotlin API returns the super-interface:
ITrough is generated even though only Manger is reachable. A super-interface that cannot be generated this way, a generic one (Satchel<T>) or one in a package you did not export, has its async members declared on the derived interface instead, so IHaversack : Satchel<Int> still declares FetchAsync.
interface Feed<T> (a generic interface) declares its async members on IFeed<T> like any other interface; see A generic interface. An eligible sealed interface is excluded from this, since it never gets an I<Name> declaration; its async members are declared on the abstract class instead, see Async members on a sealed base.
Breaking: a hand-written C# class implementing an interface that gains async members this way stops compiling (CS0535 on the new members and on DisposeAsync), the same way adding a lambda parameter to I<Name> already could. See Async members in a C# implementation for how to implement them.
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:
A member can take and return anything an ordinary member can, with any number of parameters: classes (generic ones too), interfaces, collections, value classes, nullable primitives and enums, Char, Instant, Duration, Uuid, ByteArray, lambdas, suspend and Flow members, and a Throwable, Exception or RuntimeException as a System.Exception (see Throwable values). See every member shape below.
A member with its own type parameter (fun <R> pounce(...)) is declared with R erased to object?, and Kotlin calls your implementation at R = Any?:
On a generic interface the interface's own T stays typed: interface Sniffer<T> { fun <R> sniff(f: (T) -> R): R } is object? Sniff(Func<T, object?> f) on ISniffer<T>. A Kotlin class implementing it (Nose<T>) keeps R Sniff<R>(Func<T, R> f) on the class, so only a value held as ISniffer<T> returns object?. A parameter that is reified, has more than one bound, or has a bound naming another type parameter has no single erased type: such an interface still has no bridge, the build warns once with SKIPPED_UNIMPLEMENTABLE_INTERFACE, naming the member, and passing an implementation of it throws NotSupportedException the first time it crosses.
A var works both ways: Kotlin's write lands on your C# property, and a later read sees it.
The same holds for a var on a listener passed to an add/remove pair. A lambda-typed property (val onPounce: (Int) -> String, var cue: ((String) -> String)?) is a Func<>/Action<> property on your class, suspend ones included; Kotlin reads it and writes a var back to your object. A Kotlin-backed implementation exposes the same delegate properties, see Lambdas and callbacks.
Every member shape
Implement each member as I<Name> declares it. A class Kotlin hands you is borrowed for the call, and a class you return becomes Kotlin's:
Lambdas. A lambda Kotlin passes in is a
Func<>/Action<>you call. A suspend lambda takes aCancellationTokenand returns aTask. A lambda you return is an ordinaryFunc<>/Action<>.Flows. A
Flowmember can emit classes, enums, value classes and nullable elements, can be nullable, and avarflow member has a setter Kotlin writes through. Asuspendmember can return aStateFlow. Make flows withKotlinFlow<T>.FromorKotlinMutableStateFlow<T>.Create, see Async members.Inherited members. A member a generic super-interface declares is typed with the argument the sub-interface supplies: for
interface Hamper : Basket<String>, implementstring Put(string item).Faults. An exception your member throws is catchable in Kotlin, whatever the member's type.
Async members
A suspend member is a Task-returning method with a CancellationToken, and a Flow<T> member returns a KotlinFlow<T>. Implement them as IFeeder declares them:
Cancellation. When Kotlin cancels the call, for example on a
withTimeoutOrNull, theCancellationTokenyour method received is cancelled. A cancelled task is a cancellation in Kotlin, not a failure.Faults. An exception your method throws, synchronously or from its task, is catchable in Kotlin as a
Throwable. If Kotlin does not catch it, the awaitingTaskon the C# side faults.KotlinFlow<T>.From. It wraps anyIAsyncEnumerable<T>and is cold: each Kotlincollectenumerates the source afresh, and Kotlin stopping early disposes your enumeration. AKotlinFlow<T>you got from Kotlin can be returned as it is.Element types. A
Flowmember's element can be any type a member can take, including a class, an enum, a value class and a nullableString; see every member shape. Asuspendmember returns the same types,Throwableincluded.A
suspendmember that returns aFlowisTask<KotlinFlow<T>>. Kotlin awaits the task and then collects the flow. On a generic interface the element can be the interface's ownT(ILitter<T>).Flow<Throwable>. Each exception you emit arrives in Kotlin as aNugetManagedExceptionwhose message isType: message.Hot flows. A
StateFlow,SharedFlowor mutable member must hand Kotlin a real Kotlin flow. Make one withKotlinMutableStateFlow<T>.Create(initial)orKotlinMutableSharedFlow<T>.Create(replay); a write on either side is seen on the other. Dispose a flow you made withCreate, asDisposereleases its scope. A hot flow read from a Kotlin-backed object, such asLevelof a KotlinIThermostat, or returned by one of its member functions, can be returned from your member as it is.
An interface that no function takes, returns or stores still declares its suspend members on I<Name>, so a C# class can implement it for the one place a C# value reaches Kotlin.
A generic interface
A generic Kotlin interface binds at every position (a parameter, return, property, nullable, collection component, suspend result, Flow element, lambda payload or result, and inside a generic function or class). A C# class can implement it and cross to Kotlin with the interface's T as a property, var, method parameter or result:
A value Kotlin implements arrives as the generic wrapper Keepsake<T>, which implements IKeepsake<T>. A value you implemented comes back as the same object. Closed (Keepsake<String>, Keepsake<Int>), open (fun <T>), bounded and self-bounded T all work, and so does a Box<..> argument, a nested Keepsake<Keepsake<String>> and a class member typed by the class's own T. You can choose any instantiation yourself, value types included (class Tally : IKeepsake<short>), and pass it inside a collection or a Box<IKeepsake<short>>.
A constrained interface restates its bound: interface Perch<T : Furry> is IPerch<T> where T : IFurry. A stdlib bound such as Comparable<T> drops to where T : notnull with INFO_DROPPED_BOUND. An interface member with its own type parameter (fun <R> map(...)) is declared on IKeepsake<T> with R erased to object?, and Kotlin calls your implementation at R = Any? (see Implementing a Kotlin interface in C#).
A generic interface's suspend and Flow members bind too: interface Heirloom<T> declares Task<T> PolishAsync(...) and KotlinFlow<T> Glints() on IHeirloom<T>, which is IAsyncDisposable. A C# class implements them like any other async member, with T as the closed type argument: Task<T> FetchAsync(int shelf, CancellationToken cancellationToken = default).
Kotlin-backed implementations of the interface still cross as usual, and IPet still declares every member. A marker interface with no members bridges too: Kotlin receives an empty object that implements it, and handing it back to C# returns your original instance. (Before, passing one threw.)
A member whose C# name clashes with a generated one (token, state, error, release, or a C# keyword such as lock) is handled for you; implement the member under its usual name.
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, a Flow<T> element, or any of the three erased generic routes (a Func<IPet, ...> result, a fun <T> return, or an ADR-147 generic class's T member), resolves to the original C# instance, not a fresh wrapper:
An erased type argument spells an exported interface as the interface itself, never the ADR-040 backing wrapper: PetRelayKt.PetRelay() returns Func<IPet, IPet>, not Func<Pet, Pet>. A consumer that named the backing wrapper type explicitly stops compiling; var and passing the result straight on keep working.
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:
The same holds for an interface reached only as a Flow/StateFlow element, a bare suspend result, or a member of another interface: no other return or parameter of its type is needed for it to bind.
Method overloads on an interface
Two or more same-named methods on a Kotlin interface collapse into one natural C# overload set on the generated interface, exactly like an ordinary class's overloads; the numbering that keeps the export symbols distinct never reaches the C# surface:
This works the same way whether the interface reaches C# through a return value or a parameter:
As on the class route, C# cannot overload on reference nullability alone: a fun tag(s: String)/fun tag(s: String?) pair on an interface fails generation with ERROR_CSHARP_SIGNATURE_COLLISION instead of producing invalid C#.
Choosing the C# name
A property and a method with one Kotlin name on an interface collide in C#. Put @CSharpName on the interface member; implementing classes inherit the name without repeating it (see Choosing the C# name for the rules):
An interface extending another interface
interface Derived : Base renders real C# interface inheritance: IDerived declares only its own members and inherits the rest, the way C#'s own IList<T> : ICollection<T> does:
A C# reference typed as any ancestor works, including one returned from Kotlin behind the return-value backing wrapper and one a plain C# class implements:
Implementing IHouseCat in C# obliges every member the whole hierarchy declares, Name/Nickname/Greet()/Age/Feed(string)/Purr(int): that full set is exactly what the C#-implemented-interface bridge reads when Kotlin calls back into it, whichever ancestor's parameter position it crosses at.
A generic super-interface keeps its type argument on the derived interface, the same as a class implementing one does:
A super-interface outside the plugin's export scope (a dependency-module or unbound-package type) is dropped from the base list, SKIPPED_UNEXPORTED_SUPERTYPE, and its members are declared directly on the derived interface instead, so a C# implementer can still satisfy them:
Two unrelated supers declaring the same member (a diamond) redeclare it with new instead of being dropped, since C# would otherwise leave the call ambiguous:
An override that narrows a kept super's return type (override fun greet(): Cat over a super's fun greet(): Pet) cannot be redeclared without also generating an explicit interface implementation on every implementer, so it stays off the derived interface entirely, named SKIPPED_UNSUPPORTED_COMBINATION: call it through the ancestor interface, at the ancestor's type.
Nested interfaces
An interface nested inside a non-generic 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 generic class or an enum class is skipped, and any member typed with it is skipped too, each named on a build warning rather than silently dropped.
A member extension declared in an interface (val String.shout: String, fun String.echo(times: Int): String) is declared on the generated C# interface with the receiver as its first argument (GetShout(string receiver)). A Kotlin or C# class can implement it, over a class receiver too (GetHideout(Loafer receiver)). A member extension in a sealed base or arm binds as an instance method. See Member extensions.
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. A hand-written C# subclass compiles too, since the generated Interop.cs ships as <Compile Include>d source and lands in the same assembly; its constructor chains : base(IntPtr.Zero, out _) instead of calling the base's public constructor.
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 lambda-typed property a subclass overrides is declared once, on the base that carries it: reading napper.OnWake through the subclass runs the subclass's lambda through Kotlin dispatch, and the subclass declares no second OnWake. A subclass of a generic base declares it itself.
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:
When the unexported base only redeclares a property that an exported base further up already declares abstract, the re-homed member renders abstract override, so a concrete subclass binds to the single inherited slot. A property that is new on the unexported base stays plain abstract:
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.
An abstract fun returning Flow<T> binds like any other Flow member: the abstract class declares it, so await foreach works on a value typed as the class, and the Kotlin subclass's override supplies the items. Dispose the value with await using to drain it, as for any Flow member.
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.
An abstract class as a return type
A function, property or list typed as an abstract class hands you that abstract type. C# cannot construct it, so the value is an internal subclass that forwards each abstract member to the Kotlin object. is against the concrete Kotlin subclass is false, but every member answers as that subclass, and you dispose it like any returned class:
An abstract class that extends another abstract class, or an abstract sealed arm, comes back the same way at any depth. A Napper : Hibernator returned as Napper answers Name and Weigh(), which Hibernator leaves open, as well as its own members.
A generic abstract class returned at a closed type comes back the same way, and so does an abstract class below one. A closed generic class binds at member positions too (see Generics):
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 sealed base's own supertype
A sealed class's own supertype crosses the same way an ordinary class's does. An exported supertype is named in the sealed base's C# base list, so is/as against it work and its own members stay on it:
An unexported supertype (a dependency-module or unbound-package type) is dropped from the base list instead, named SKIPPED_UNEXPORTED_SUPERTYPE, and its public members are re-homed onto the sealed base, reachable there and, through ordinary C# inheritance, on every arm:
Swaddle.Wriggling's override of Shake() renders override, since the base now declares it virtual; Swaddle.Still declares nothing of its own and inherits the base's re-homed Shake() through ordinary C# dispatch. This applies to an unexported interface supertype too, including an abstract member with no default the sealed base itself never implements: it still re-homes onto the base and every arm implements it as it would any other inherited abstract member.
A sealed arm's own interfaces
A sealed arm that implements an interface lists it in its own C# base list, beside the sealed base, so arm is IFoo holds and every interface member is callable through the arm — including one it only inherits as a default, or reaches through a super-interface:
A sealed arm is a nested type, so its base list is qualified even for a same-namespace interface (global::Interop.ISunseeker, not bare ISunseeker): the qualification avoids binding to a differently-named nested type declared beside it. A top-level class's same-namespace base list stays bare (TrainingClicker : Scoreboard, ITally above).
An interface an arm cannot list, because it is unexported or otherwise unspellable, is dropped from the arm's base list the same way it would be for an ordinary class (SKIPPED_UNEXPORTED_SUPERTYPE), with its members re-homed onto the arm directly:
Subclass placement and pattern matching
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: every subclass is a class/object or enum class, nested in the interface or declared beside it, with no other superclass, no sub-interface, and no second sealed-interface parent. 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 a sub-interface arm, keeps its plain IFoo declaration instead of becoming an abstract class. When every arm is a class C# can construct, it still binds, through a discriminator on the interface itself: see A sealed interface whose arms extend a class. When any arm is not, every function, property, or parameter typed with it is skipped, named on a build warning. There is no partial binding: fix the arm the warning names, or use a sealed class instead.
A sealed interface whose arms extend a class
A sealed interface whose arms already extend a class, typically the arms of a sealed class, binds as the C# interface I<Name> rather than an abstract class. Any arm passes where the interface is expected, and a returned value is the concrete arm, so it pattern matches and is also an instance of its class base:
The interface keeps its I declaration, and each arm keeps its class base and lists the interface:
It binds wherever a sealed class does: a parameter, return, nullable return, val/var property, List/Set/Map component, constructor parameter, suspend parameter and result, Flow and StateFlow item on a class member, and a member of an arm. Every returned value is a fresh, owned arm: dispose it. The switch needs a discard arm, since C# cannot check a hierarchy for exhaustiveness.
Which arms bind. Every arm must be a class C# can construct, and one refused arm refuses the whole interface, with a SKIPPED_INELIGIBLE_SEALED_INTERFACE warning that names it:
An arm that is | Binds |
|---|---|
a class or | yes |
an exported, top-level, non-abstract, non-generic class, with or without a superclass | yes |
a class that also implements a second sealed interface of this kind | yes |
a bare | no |
generic, or an arm of a generic sealed class, or the interface is generic | no |
abstract, or an intermediate | no |
nested in the interface with no sealed class base | no: declare it beside the interface |
a subclass of another arm of the same interface | no |
not exported | no |
What to know before relying on it:
The C# shape follows the arms. Giving one arm of an eligible sealed interface a superclass flips it from
abstract class Pulsetointerface IPulse, which breaks C# consumers at compile time.A class that implements two such interfaces, like
RemoteDeviceabove ordata class Both : Left, Right, binds as bothILeftandIRightover the one class. It was refused before.Do not implement
I<Name>in C#. An ordinary implementation fails to compile (CS0535 on the internal_handlemember); an implementer that works around it is unsupported, and nothing checks for it at run time.A callback payload of the interface type is not bound, the same as for a sealed class. A nullable parameter of it binds, as for a sealed class.
A sealed type with no discriminator at a
Flowmethod orStateFlowproperty item, such as a refused sealed interface, skips with a named warning. It used to emit a C# name nothing declares and break the whole package build.
An enum class arm
An enum class arm, alone or mixed with class/object arms, binds as a boxed arm: public sealed class {Enum}Arm : Base, holding the enum entry, with a public constructor from the C# enum and a Value getter back to it. The C# enum is still declared exactly once, keeping its own extension methods; a boxed arm adds no second spelling for those members.
swirl.Value.Patch() reaches Swirl's own member through the ordinary enum extension method, never a second spelling on the box. A boxed arm is IDisposable like any other arm; new PatchArm(Patch.Socks) without using holds a handle to a permanent Kotlin singleton until you dispose it or the GC finalizes it, the same as an unboxed arm constructor. There is no implicit conversion from the C# enum to the sealed base (Marking m = Patch.Bib does not compile): it would mint a handle the caller never sees and cannot dispose. A declared type already named {Enum}Arm in the same namespace refuses the interface, naming the collision, rather than colliding silently. An enum class arm cannot join a sealed interface whose other arms extend a class: that route refuses enum arms, so the whole interface is skipped, named.
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); a sealed interface whose arms extend a class binds the same way, as I<Name>. 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, widened default parameters, nullable types, collections, and Duration/Uuid /value-class/interface shapes all work on an arm too, exported under the arm's own name. A member the arm merely inherits from one of its own interfaces — a default it never overrides itself, or one reached through a super-interface — binds the same way, under the arm's own name (see A sealed arm's own interfaces above). Only a member inherited from the sealed base itself stays off the arm: it binds once, on the base, described next, rather than being re-exported per arm.
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.
Async members on a sealed base and its arms
A suspend fun, Flow<T> or StateFlow<T> member the sealed base declares, abstract or with a default body, binds on the C# base itself, so a caller holding the base can use it without pattern-matching to an arm. Calls dispatch to the arm's override, or to the base's default when no arm overrides it, including for an enum class arm:
The base owns the one coroutine scope. Every arm overrides DisposeAsync() to drain it, and keeps IDisposable too, so using still compiles. A sealed class base works the same way (Job.RestAsync for open suspend fun rest()). A base member that cannot bind is a named skip with the reason the ordinary class route gives.
A member only an arm declares (Shape.Loaf.knead(times)) binds as Task<T> KneadAsync(...) on that arm and uses the base's scope, so await using on the base reference drains it too.
Breaking: the arm no longer declares its own copy of a base-declared member, and its own scope moves to the base. C# source keeps compiling, since loaf.AreaAsync() now resolves to the inherited Shape.AreaAsync. A consumer compiled against an earlier package must be rebuilt.
A generic sealed base does not carry these members; they bind on each arm, see Members of a generic arm.
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. A flow member the sealed base itself declares binds on the base instead, see above, unless the base is generic: then it binds on each arm (Members of a generic arm). Suspend and flow members share the base's one 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:
A lambda an arm or the base returns binds as a delegate too, suspend and nullable included. A member declared on the base is present on every arm:
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 and abstract 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.
An abstract arm renders public abstract class, with its abstract members abstract and its open members virtual, so an exported Kotlin subclass of it compiles in C#. Like an open arm's subclass, that subclass comes back typed as the arm, through an internal subclass of the arm that forwards the abstract members to Kotlin, so is Torpor.Dormant holds, is DeepTorpor does not, and every member answers as the Kotlin DeepTorpor:
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. A generic class hosts them on a non-generic holder beside it. Only an enum class or a generic interface still cannot host a nested declaration.
A generic sealed hierarchy
A sealed class (or an eligible sealed interface) with a type parameter binds as a generic abstract class. Its arms are declared on a non-generic static class of the same name, so Kotlin's Outcome.Ok<Int> is Outcome.Ok<int> in C#:
Err fixes the base's argument to Nothing, which C# cannot express, so it is generic in C# too. Its type parameter is a placeholder: choose it to match the Outcome<T> you hold or need (Err<int> is an Outcome<int>, Err<string> an Outcome<string>). They are two C# types over one Kotlin class. A data object arm such as Loading<T> is only ever received; it has no public constructor. Arms you build in C# can be passed anywhere Kotlin wants the base.
The same rule decides every arm:
An arm that forwards the base's parameter (
Ok<T> : Outcome<T>) is generic over it. An arm that permutes or renames parameters lists them in the base's order:Flip<X, Y> : Duel<Y, X>isDuel.Flip<Y, X>.An arm that fixes a covariant (
out) parameter gets a placeholder parameter, asErrdoes.Under an invariant parameter, an arm that closes the argument keeps Kotlin's exact shape:
class IntCell : Cell<Int>()isCell.IntCell : Cell<int>, with no placeholder.An intermediate
sealedarm is abstract and has its own arms, and it follows the same rule:sealed class Lapse : Outcome<Nothing>()isOutcome.Lapse<T>withOutcome.Lapse.Stall<T>, whilesealed class Spare : Cell<Int>()staysCell.SparewithCell.Spare.Last.An arm restates the base's constraints, so a base with
T : DozergivesClaimed<T> where T : IDozer.A type declared beside the arms (
Outcome.Detail) lives on theOutcomeholder, and an arm declared beside the base in the same file is declared at namespace level, as for any sealed class.An arm cannot be named like its base (
Outcome.Outcome): the build fails withERROR_CSHARP_NAME_COLLISION, so rename the arm.
A position typed as an arm that fixes Nothing (fun fail(): Outcome.Err) is Outcome.Err<KotlinNothing>. KotlinNothing comes from Kotlin.Native.Interop; it can never be instantiated.
A closed instantiation (Outcome<Int>, Outcome<String>?) binds as a parameter, return, property, constructor parameter or List element, as a Flow element returned from a class method, and in a returned lambda (Func<Outcome<int>, Outcome<int>>). The argument can be a primitive, String, an exported class, object, interface or enum, or a type parameter of the declaring class (Hamper<T>.Wrap(): Outcome<T>). A Hamper<Outcome<long>> you pick yourself works too, once you hold an Outcome<long> to put in it.
A consumer cannot convert between instantiations: an Outcome.Err<KotlinNothing> is not an Outcome<int>. This is C# invariance, the same rule that stops a List<string> becoming a List<object>. Build the Err<int> instead.
A star projection (Outcome<*>) reads at its bound, so a parameter of that type is Outcome<object>.
These are skipped with a named SKIPPED_SEALED_POSITION warning. The member is absent and the rest of its owner still binds:
An argument the bridge cannot read through an erased slot (
Outcome<List<Int>>, a lambda or aFlowas the argument).Nothingunder a constraint the marker fails:Blanket.FoldedisBlanket<Nothing>where C# restatesT : IDozer, andKotlinNothingdoes not implementIDozer.
An arm that declares a parameter its base never sees (class Both<T, U>(...) : Outcome<T>()) is declared without it (Outcome.Both<T>), since C# cannot recover U from an Outcome<T>. Its constructor and every member that names U are skipped, so you receive a Both from Kotlin but cannot construct one.
Members of a generic arm
An arm that forwards the base's type parameter (Hot<T> : Dish<T>) binds its flow members, per-call lambda parameters, stored-callback pairs and interface-listener pairs. Each behaves as it does on an ordinary class, and the arm owns the scope its flow members collect on, so await using it. A flow member declared on a generic base binds on each arm, not on the base: Dish<T> carries no Served, so match to an arm before collecting. A non-generic sealed base puts the same member on the base (see Async members on a sealed base and its arms).
Bare is not generic in Kotlin, but C# declares it as Dish.Bare<T> (like Err above), and every member of it binds, suspend ones included: await mylo.WipeAsync().
A suspend member on an arm with a Kotlin type parameter binds like a generic class's: Hot<T>.serve(): T becomes Task<T> ServeAsync(CancellationToken), an override of the generic base's suspend member binds on the arm, and an arm implementing an exported interface with a suspend member implements its IAsyncDisposable and the interface member. See Coroutines and Flow.
A lambda over the arm's T binds as a per-call parameter (fun each(action: (T) -> Unit) is Each(Action<T>)), as a per-call result (fun pick(cb: (Char) -> T): T is Pick(Func<char, T> cb)) and in a stored pair (addTaste/removeTaste over (T) -> Unit is IDisposable AddTaste(Action<T> listener)). The payload is typed by the closed T, so Dish.Hot<string> hands your callback a string.
A var flow property on a generic arm has a setter; see A var flow property.
Limitations
A C#-implemented interface with a member whose own type parameter is
reified, has several bounds or is bounded by another type parameter has no bridge: passing an implementation throwsNotSupportedExceptionthe first time. The build names the member on oneSKIPPED_UNIMPLEMENTABLE_INTERFACEwarning per interface.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.
An interface's
companion objectmembers (functions,val,const val) are not bound: the C# interface has no statics. Each is skipped with a named warning; move it to a top-level declaration or anobject.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.
A sealed interface with an arm that no route can construct (a bare
object, anenum classbeside arms with a superclass, a sub-interface, an abstract or generic arm, and the other rows of the arm table) has no binding at all: every function, property, or parameter typed with it is skipped, named.An
enum classarm's boxed constructor andValuegetter cost a handle and a P/Invoke each; see Anenum classarm for the disposal obligation and the missing implicit conversion.An
objectarm, or aclass-kind arm whose every constructor is refused, has only theinternalhandle constructor, sonew Base.Arm(9)fails to compile rather than binding it; obtain the arm from a factory or from the base'sFromHandlediscriminator instead. Aclass-kind arm with bridgeable constructor parameters exports a real public constructor instead; see Sealed classes and interfaces.A generic sealed hierarchy skips a few uses, named: an argument the bridge cannot read,
Nothingunder a constraint, and an arm parameter the base never sees. See A generic sealed hierarchy.A generic method on an arm or a sealed base binds as a C# generic method; see Generic methods on a class. A
suspendlambda parameter binds on an arm as well (not on an arm with its own Kotlin type parameter, which skips anysuspendmember, see Members of a generic arm), as does aFlow<T>parameter of a primitive orString, suspend or not; see Asuspendlambda as a parameter and AFlowas a parameter.A member whose C# name equals a type nested in the sealed base, or another arm's name (
backing()beside a nestedclass Backing), is a namedERROR_CSHARP_NAME_COLLISIONat the arm. So is a member named like its own type (OnTap.OnTap,Cat.cat) on a class, a sealed base or an arm; rename one of them. A Kotlinobjector interface member named like its own type is not checked, so it surfaces as a C# error in your build instead.