kotlin-native-nuget Help

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

interface

interface (I-prefixed)

default methods delegate to Kotlin; a super-interface's members are inherited, not redeclared; suspend/Flow/StateFlow members are declared too, and the interface becomes IAsyncDisposable when it has one

abstract class

abstract class

_handle inherited by every subclass; a value typed as the class comes back as an internal subclass

sealed class

abstract class

each subtype its own class, nested inside the base or declared beside it, reconstructed through a generated FromHandle; a generic sealed class binds as Outcome<T> with arms on a non-generic Outcome holder (Outcome.Ok<T>)

eligible sealed interface (every subclass a class/object or enum class, no other superclass, no sub-interface, no second sealed-interface parent)

abstract class

same shape as sealed class; no C# interface is declared for it; an enum class arm binds as a boxed {Enum}Arm

sealed interface whose arms extend a class or implement a second sealed interface, every arm an exported class

interface (I-prefixed)

binds at every sealed position; a returned value is the concrete arm; see A sealed interface whose arms extend a class

any other sealed interface

interface (I-prefixed)

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:

interface Pet { val name: String fun speak(): String fun greet(): String = "Hi, I'm $name" }
public interface IPet : IDisposable { string Name { get; } string Speak(); string Greet(); }

Cat : IPet only declares Name and Speak(); Greet() still works, reached through the interface's own default body:

using IPet pet = new Cat("Oreo", 9); pet.Greet(); // "Hi, I'm Oreo" - the default, never overridden

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:

using IPet? friend = oreo.Friend; if (friend is not null) friend.Speak();

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:

interface Greeter { val greeting: String get() = "hello" fun greet(): String = "$greeting from a $species" val species: String } class Parrot(override val species: String) : Greeter
using var parrot = new Parrot("macaw"); parrot.Greeting; // "hello" - Parrot never declared this parrot.Greet(); // "hello from a macaw"

If the implementing class is itself open (or abstract), its inherited default renders public virtual instead, so a further Kotlin subclass can override it:

interface Scratcher { fun scratch(): String = "a quick scratch" } open class ScratchingPost : Scratcher class CarpetPost : ScratchingPost() { override fun scratch(): String = "shreds the carpet post" }
public class ScratchingPost : IScratcher, IDisposable, INugetHandle { public virtual string Scratch() { /* ... */ } // ScratchingPost never declared this itself } public class CarpetPost : ScratchingPost { public override string Scratch() { /* ... */ } }

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 val always compiles: the implementing class's property renders virtual, not override, and a virtual declaration is free to add a setter the interface never declared.

  • Widening an exported base class's own val, or a var whose 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-only virtual property, so a derived { get; set; } override of 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's var (below), the setter is not lost: it is still reachable, just not through the public property.

abstract class Animal(override val name: String) : Pet { override val vibe: String = "calm" // implements Pet.vibe: renders virtual, get-only } class Cat(name: String) : Animal(name) { override var vibe: String = "curious" // widens Animal's val to var }

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:

interface Tally { var count: Int var lastSlip: IllegalStateException? }
public interface ITally : IDisposable { int Count { get; set; } Exception? LastSlip { get; } // no setter: narrower than RuntimeException }

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:

open class Scoreboard { open val count: Int = 0 } class TrainingClicker : Scoreboard(), Tally { override var count: Int = 0 override var lastSlip: IllegalStateException? = null }
public class TrainingClicker : Scoreboard, ITally { public override int Count { get { /* ... */ } } // stays get-only: CS0546 forbids a setter here int ITally.Count // reaches the same setter export the public property couldn't carry { get => Count; set { /* ... */ } } public override global::System.Exception? LastSlip { get { /* ... */ } } // no explicit member: the setter is refused entirely }
ITally tally = clicker; tally.Count = 5; // reaches Kotlin through the explicit member clicker.Count; // 5 - the public getter agrees clicker.Count = 5; // does not compile: TrainingClicker.Count has no public setter

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:

interface Feed { suspend fun fetch(id: Int): String fun ticks(): Flow<Int> val level: StateFlow<Int> fun doubled(): Flow<Int> = ticks().map { it * 2 } // default body, declared on IFeed too fun name(): String } class RssFeed : Feed { /* overrides fetch/ticks/level/name */ } fun makeFeed(): Feed = RssFeed()
public interface IFeed : IDisposable, IAsyncDisposable { KotlinStateFlow<int> Level { get; } string Name(); Task<string> FetchAsync(int id, CancellationToken cancellationToken = default); KotlinFlow<int> Ticks(); KotlinFlow<int> Doubled(); }
await using IFeed feed = Feeds.MakeFeed(); await feed.FetchAsync(3); await foreach (int tick in feed.Ticks()) { /* ... */ } int level = feed.Level.Value; await foreach (int doubled in feed.Doubled()) { /* ... */ } // the default, reached through IFeed

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:

await using IFeed crate = Feeds.MakeCrate(); // wraps Crate<int> await crate.FetchAsync(3); // compiles: reached through IFeed var direct = new Crate<int>(1); // direct.FetchAsync(3) does not compile: FetchAsync is IFeed's explicit member, not Crate<T>'s own

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.

class Pantry { interface Bowl { suspend fun fill(scoops: Int): String fun kibbles(): Flow<Int> val level: StateFlow<Int> } fun open(): Bowl = OreoBowl() }
public class Pantry : IDisposable, INugetHandle { public interface IBowl : IDisposable, IAsyncDisposable { KotlinStateFlow<int> Level { get; } Task<string> FillAsync(int scoops, CancellationToken cancellationToken = default); KotlinFlow<int> Kibbles(); } }
using var pantry = new Pantry(); await using Pantry.IBowl bowl = pantry.Open(); string filled = await bowl.FillAsync(3);

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:

interface Trough { suspend fun fetch(id: Int): String fun ticks(): Flow<Int> val level: StateFlow<Int> } interface Manger : Trough { fun own(): Int } fun makeManger(): Manger = Stall()
public interface ITrough : IDisposable, IAsyncDisposable { /* FetchAsync, Ticks, Level */ } public interface IManger : ITrough, IDisposable, IAsyncDisposable { int Own(); }
await using IManger manger = Mangers.MakeManger(); string item = await manger.FetchAsync(7); // declared on ITrough, inherited by IManger

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:

private class Dog : IPet { public string Name { get; } public int Legs => 4; public string? Nickname { get; } public Dog(string name) { Name = name; } public string Speak() => "Woof!"; public string Greet() => $"Hi, I'm {Name} the dog"; public string Fetch(string item) => $"{Name} fetches the {item}"; public void Nap() { } public void Dispose() { } } using IPet dog = new Dog("Rex"); oreo.Befriend(dog); oreo.ClosestFriend().Speak(); // "Woof!" - Kotlin actually called into `dog`

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?:

class Mouse(val name: String) { fun squeak(): String = "$name squeaks" } interface Pouncer { fun <R> pounce(f: (String) -> R): R } fun pouncing(pouncer: Pouncer): String = pouncer.pounce { Mouse(it) }.squeak()
internal sealed class Ambush(string name) : IPouncer { public object? Pounce(Func<string, object?> f) => f(name); public void Dispose() { } } Assert.Equal("Oreo squeaks", Hoarding.Pouncing(new Ambush("Oreo")));

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.

interface Collar { var tag: String var bells: Int } fun retag(collar: Collar, tag: String): String { collar.tag = tag return collar.tag }
private sealed class BreakawayCollar : ICollar { public string Tag { get; set; } = "Mylo"; public int Bells { get; set; } = 1; public void Dispose() { } } var collar = new BreakawayCollar(); Retagging.Retag(collar, "OREO"); // "OREO"; collar.Tag is now "OREO"

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:

interface Sitter { fun adopt(name: String): Kitten fun foster(kitten: Kitten?): Kitten? fun pack(item: String): Crate<String> fun litter(kittens: List<Kitten>): Set<String> fun trim(whisker: Whisker): Whisker // a value class fun treats(cat: String): Int? fun coat(cat: String): Coat? // a nullable enum fun lastFed(at: Instant): Instant fun photo(bytes: ByteArray): ByteArray fun onPurr(callback: (String) -> Int): Int fun purrer(): (Int) -> String suspend fun whenHungry(callback: suspend (String) -> Int): Int fun groom(cat: String, strokes: Int, minutes: Double, gentle: Boolean): String }
internal sealed class CatSitter : ISitter { public Kitten Adopt(string name) => new(name); public Kitten? Foster(Kitten? kitten) => kitten == null ? null : new Kitten($"{kitten.Name} jr"); public Crate<string> Pack(string item) => new($"crate of {item}"); public IReadOnlySet<string> Litter(IReadOnlyList<Kitten> kittens) => kittens.Select(kitten => kitten.Name.ToUpperInvariant()).ToHashSet(); public Whisker Trim(Whisker whisker) => new(whisker.Length * 2); public int? Treats(string cat) => cat == "Oreo" ? 3 : null; public Coat? Coat(string cat) => cat == "Oreo" ? TestLibrary.Catsitter.Coat.Tuxedo : null; public DateTimeOffset LastFed(DateTimeOffset at) => at.AddHours(1); public byte[] Photo(byte[] bytes) => bytes.Reverse().ToArray(); public int OnPurr(Func<string, int> callback) => callback("Mylo") + 1; public Func<int, string> Purrer() => times => $"purr x{times}"; public async Task<int> WhenHungryAsync( Func<string, CancellationToken, Task<int>> callback, CancellationToken cancellationToken = default) => await callback("Oreo", cancellationToken); public string Groom(string cat, int strokes, double minutes, bool gentle) => $"groomed {cat} {strokes} strokes for {minutes} min{(gentle ? " gently" : "")}"; /* the remaining members, Dispose and DisposeAsync omitted */ }
  • Lambdas. A lambda Kotlin passes in is a Func<>/Action<> you call. A suspend lambda takes a CancellationToken and returns a Task. A lambda you return is an ordinary Func<>/Action<>.

  • Flows. A Flow member can emit classes, enums, value classes and nullable elements, can be nullable, and a var flow member has a setter Kotlin writes through. A suspend member can return a StateFlow. Make flows with KotlinFlow<T>.From or KotlinMutableStateFlow<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>, implement string 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:

interface Feeder { val name: String suspend fun feed(cat: String): Int fun meals(): Flow<String> } suspend fun serve(feeder: Feeder, cat: String): String = "${feeder.name} gave $cat ${feeder.feed(cat)} scoops" suspend fun menu(feeder: Feeder): String = feeder.meals().toList().joinToString()
private sealed class Bowl(string name) : IFeeder { public string Name { get; } = name; public async Task<int> FeedAsync(string cat, CancellationToken cancellationToken = default) { await Task.Delay(5, cancellationToken); return cat == "Oreo" ? 3 : 5; } public KotlinFlow<string> Meals() => KotlinFlow<string>.From(Courses()); private static async IAsyncEnumerable<string> Courses( [EnumeratorCancellation] CancellationToken token = default) { foreach (string course in new[] { "kibble", "tuna" }) { await Task.Delay(1, token); yield return course; } } public void Dispose() { } public ValueTask DisposeAsync() => default; } using var bowl = new Bowl("Oreo's bowl"); await Feeders.ServeAsync(bowl, "Oreo"); // "Oreo's bowl gave Oreo 3 scoops" await Feeders.MenuAsync(bowl); // "kibble, tuna"
  • Cancellation. When Kotlin cancels the call, for example on a withTimeoutOrNull, the CancellationToken your 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 awaiting Task on the C# side faults.

  • KotlinFlow<T>.From. It wraps any IAsyncEnumerable<T> and is cold: each Kotlin collect enumerates the source afresh, and Kotlin stopping early disposes your enumeration. A KotlinFlow<T> you got from Kotlin can be returned as it is.

  • Element types. A Flow member's element can be any type a member can take, including a class, an enum, a value class and a nullable String; see every member shape. A suspend member returns the same types, Throwable included.

  • A suspend member that returns a Flow is Task<KotlinFlow<T>>. Kotlin awaits the task and then collects the flow. On a generic interface the element can be the interface's own T (ILitter<T>).

  • Flow<Throwable>. Each exception you emit arrives in Kotlin as a NugetManagedException whose message is Type: message.

  • Hot flows. A StateFlow, SharedFlow or mutable member must hand Kotlin a real Kotlin flow. Make one with KotlinMutableStateFlow<T>.Create(initial) or KotlinMutableSharedFlow<T>.Create(replay); a write on either side is seen on the other. Dispose a flow you made with Create, as Dispose releases its scope. A hot flow read from a Kotlin-backed object, such as Level of a Kotlin IThermostat, or returned by one of its member functions, can be returned from your member as it is.

private sealed class Boiler : IThermostat { public KotlinMutableStateFlow<int> LevelFlow { get; } = KotlinMutableStateFlow<int>.Create(18); public KotlinSharedFlow<string> News() => Headlines; public KotlinMutableSharedFlow<string> Headlines { get; } = KotlinMutableSharedFlow<string>.Create(replay: 1); public KotlinStateFlow<int> Level => LevelFlow; /* Dial, Bell, Dispose and DisposeAsync omitted */ } using var boiler = new Boiler(); boiler.LevelFlow.Value = 23; // a Kotlin function reading `thermostat.level.value` sees 23

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:

interface Keepsake<T> { var item: T } fun inspect(keepsake: Keepsake<String>): String = "curator sees ${keepsake.item}" fun <T> trade(keepsake: Keepsake<T>, value: T): T // returns the old item fun <T> locketOf(item: T): Keepsake<T> = Locket(item) class Trove { var kept: Keepsake<String> = Locket("Oreo") fun all(): List<Keepsake<String>> = listOfNotNull(kept) }
private sealed class Diary : IKeepsake<string> { public string Item { get; set; } = "Oreo"; public void Dispose() { } } using var diary = new Diary(); Keepsakes.Inspect(diary); // "curator sees Oreo" Keepsakes.Trade(diary, "Mylo"); // "Oreo"; diary.Item is now "Mylo" using var trove = new Trove(); trove.Kept = diary; Assert.Same(diary, trove.Kept); // your own object comes back Assert.Same(diary, trove.All()[0]); using var locket = Keepsakes.LocketOf(41L); // Kotlin-backed: a Keepsake<long>, yours to dispose locket.Item += 1;

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:

oreo.Befriend(dog); oreo.Friend; // the same `dog` instance, not a new Pet wrapper Func<IPet, IPet> relay = PetRelayKt.PetRelay(); relay(dog); // the same `dog` instance too

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:

object Boarding { interface Clerk { fun stamp(): String } fun fileVia(clerk: Clerk): String = "${clerk.stamp()} filed at boarding" }
private sealed class DeskClerk : Boarding.IClerk { public string Stamp() => "stamped"; public void Dispose() { } } Boarding.FileVia(new DeskClerk()); // "stamped filed at boarding"

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:

interface Brusher { fun brush(): String fun brush(strokes: Int): String fun brush(mood: Mood): String fun trim(claws: Int = 4): String fun trim(paw: String, claws: Int = 1): String }
public interface IBrusher : IDisposable { string Brush(); string Brush(int strokes); string Brush(global::TestLibrary.Cat.Mood mood); string Trim(int? claws = null); string Trim(string paw, int? claws = null); }

This works the same way whether the interface reaches C# through a return value or a parameter:

using IBrusher brusher = BrusherKt.HouseBrusher(); brusher.Brush(Mood.Grumpy); // "Oreo is brushed while grumpy" - not Brush(2)'s overload salon.BrushAll(new MyloBrusher()); // each Kotlin call reaches Mylo's matching C# overload

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):

interface Advertisement { val collarTag: CollarTag? @CSharpName("CollarTagBytes") fun collarTag(code: Int): ByteArray? }
byte[]? CollarTagBytes(int code);

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:

interface Named { val name: String fun greet(): String } interface Aged { val age: Int } interface Pet : Named, Aged { fun feed(food: String): String } interface HouseCat : Pet { fun purr(times: Int): String override fun greet(): String = "Purr, I'm $name" // identical signature: not redeclared below }
public interface INamed : IDisposable { string Name { get; } string Greet(); } public interface IAged : IDisposable { int Age { get; } } public interface IPet : INamed, IAged, IDisposable { string Feed(string food); } public interface IHouseCat : IPet, IDisposable { string Purr(int times); // Greet() stays on INamed; redeclaring it here would be CS0108 }

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:

using IHouseCat oreo = Lineage.AdoptHouseCat(); INamed named = oreo; // compiles because IHouseCat is transitively an INamed named.Greet(); // "Purr, I'm Oreo" - HouseCat's own override, reached via INamed private sealed class MyloHouseCat : IHouseCat { /* must implement every inherited member too */ }

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:

interface Holder<T> { val size: Int fun peek(): T } interface IntHolder : Holder<Int> { fun shake(): String }
public interface IIntHolder : IHolder<int>, IDisposable { string Shake(); // Size/Peek() stay on IHolder<int>, not redeclared here }

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:

interface Pedigree { // never exported val breed: String fun registry(): String } interface ShowCat : Named, Pedigree { fun pose(): String }
public interface IShowCat : INamed, IDisposable { string Breed { get; } // re-homed from the unexported Pedigree string Pose(); string Registry(); // re-homed from the unexported Pedigree }

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:

interface Whiskered { val whiskers: Int; fun twitch(): String = "Whiskers twitch" } interface Tailed { val whiskers: Int; fun twitch(): String = "Tail swishes" } interface Moggy : Whiskered, Tailed { override fun twitch(): String = "twitches and swishes" // Kotlin forces this override fun nap(): String }
public interface IMoggy : IWhiskered, ITailed, IDisposable { new int Whiskers { get; } new string Twitch(); string Nap(); }

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:

abstract class Animal(override val name: String) : Pet { fun introduce(): String = "My name is $name" }
public abstract class Animal : IPet, IDisposable, INugetHandle { internal IntPtr _handle; public string Name { get { /* ... */ } } public string Introduce() { /* ... */ } public abstract string Speak(); public abstract void Dispose(); }

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:

Bed bed = new Hammock(); bed.Occupant = "Oreo"; // dispatches through Hammock's own override

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:

abstract class Vehicle(val plate: String) { abstract fun honk(): String fun describe(): String = "$plate says ${honk()}" } class Truck(plate: String) : Vehicle(plate) { override fun honk(): String = "HONK" }
public abstract class Vehicle : IDisposable, INugetHandle { public string Plate { get { /* ... */ } } public string Describe() { /* ... */ } public abstract string Honk(); }

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:

abstract class Cushion { // never exported abstract val weave: String abstract var loft: Int } abstract class Lounger : Cushion() // exported; implements neither member class Beanbag : Lounger() { override val weave: String = "corduroy" override var loft: Int = 4 }
public abstract class Lounger : IDisposable, INugetHandle { public abstract string Weave { get; } public abstract int Loft { get; set; } }

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:

public abstract class RedefinedDinghy : RedefinedVessel // RedefinedSkiff in between is unexported { public abstract override string Sail { get; } // RedefinedVessel declares it too public abstract string Rigging { get; } // new on RedefinedSkiff }

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.

abstract class Hauler(val plate: String) { abstract fun cargo(): Toy // declared in a different package }
public abstract class Hauler : IDisposable, INugetHandle { public abstract global::TestLibrary.Cat.Toy Cargo(); }

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:

abstract class Hibernator { abstract val name: String abstract fun snores(): Int open fun describe(): String = "$name snores ${snores()} times" } class OreoHibernator : Hibernator() { /* name = "Oreo", snores() = 3 */ } class Den { fun hibernator(): Hibernator = OreoHibernator() val hibernators: List<Hibernator> get() = listOf(OreoHibernator(), OreoHibernator()) }
using Hibernator hibernator = den.Hibernator(); hibernator.Describe(); // "Oreo snores 3 times" bool concrete = hibernator is OreoHibernator; // false

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):

abstract class Trove<T>(val first: T) { abstract fun pick(): T abstract val keeper: String } abstract class Alcove : Trove<String>("catnip") { abstract fun depth(): Int } class OreoTrove(first: String) : Trove<String>(first) { /* pick() = "$first flake" */ } fun stock(): Trove<String> = OreoTrove("tuna") class Hutch { fun alcove(): Alcove = OreoAlcove() }
using Trove<string> trove = CubbySample.Stock(); trove.Pick(); // "tuna flake" bool concrete = trove is OreoTrove; // false using var hutch = new Hutch(); using Alcove alcove = hutch.Alcove(); ((Trove<string>)alcove).Pick(); // "catnip mouse"

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:

sealed class Observation { data object Superposition : Observation() data class Alive(val cat: Cat) : Observation() data class Dead(val cause: String) : Observation() }
using Observation result = ObservationKt.OpenBox("Oreo"); string message = result switch { Observation.Superposition => "Unknown - cat is in superposition", Observation.Alive a => $"Alive: {a.Cat!.Name}", Observation.Dead d => $"Dead: {d.Cause}", _ => throw new InvalidOperationException(), };

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:

open class Pouffe { val stuffing: String = "beans" open fun sink(): Int = 1 } sealed class Ottoman : Pouffe() { data class Tall(val shelf: Int) : Ottoman() { override fun sink(): Int = shelf } data class Squat(val step: Int) : Ottoman() // inherits Pouffe's Sink() unchanged }
public abstract class Ottoman : Pouffe // Stuffing stays on Pouffe, not re-declared here { public sealed class Tall : Ottoman { public override int Sink() { /* ... */ } // overrides Pouffe's own member } }

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:

// UnexportedBlanket lives outside the export scope open class UnexportedBlanket { val fabric: String = "fleece" open fun shake(): Int = 1 } sealed class Swaddle : UnexportedBlanket() { data class Wriggling(val turns: Int) : Swaddle() { override fun shake(): Int = turns } data class Still(val limbs: Int) : Swaddle() // inherits the re-homed Shake() unchanged }
public abstract class Swaddle : IDisposable, INugetHandle { public virtual string Fabric { get { /* ... */ } } // re-homed from UnexportedBlanket public virtual int Shake() { /* ... */ } // re-homed from UnexportedBlanket }

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:

interface Basker { val warmth: Int fun bask(): String = "basking at $warmth degrees" } interface Sunseeker : Basker { val spot: String var naps: Int fun stretch(): String = "stretches on the $spot after $naps naps" } sealed class Sunroom { open val naps: Int = 0 class Beam(override val spot: String) : Sunroom(), Sunseeker { override var naps: Int = 0 override val warmth: Int = 30 } }
public sealed class Beam : Sunroom, global::Interop.ISunseeker { public override int Naps { get { /* ... */ } } // get-only: widens Sunroom's read-only naps (CS0546) int global::Interop.ISunseeker.Naps // ADR-168's explicit setter, now given to a sealed arm too { get => Naps; set { /* ... */ } } public string Stretch() { /* ... */ } // Sunseeker's own default, inherited, never overridden public string Bask() { /* ... */ } // Basker's default, reached through the 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).

ISunseeker seeker = beam; seeker.Naps = 5; // reaches Kotlin through the explicit member beam.Naps; // 5 - the public getter agrees

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:

class Draught(val gap: Int) : Sunroom(), UnexportedRadiator { // never exported override val fins: Int get() = gap * 2 }
public sealed class Draught : Sunroom // no interface here: UnexportedRadiator has no C# type { public int Fins { get { /* ... */ } } // re-homed, not reached through an interface public int Ticks { get { /* ... */ } } // UnexportedRadiator's own default, re-homed too public string Hum() { /* ... */ } }

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:

sealed class Nap { data class Deep(val minutes: Int) : Nap() data object Zoomies : Nap() }
public Deep(int minutes) : base(IntPtr.Zero, out _) { IntPtr handle = Native_Create(minutes, out IntPtr error); // ... _handle = 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:

sealed interface Pulse { data class Beat(val bpm: Int) : Pulse data object Flat : Pulse }
using Pulse current = monitor.Current; Assert.IsType<Pulse.Flat>(current);

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:

sealed class EvidenceDevice sealed interface ConnectableDevice { val address: String } sealed interface Chargeable { val battery: Int } data class NearbyDevice(val name: String, override val address: String) : EvidenceDevice(), ConnectableDevice data class RemoteDevice(val host: String, override val battery: Int) : EvidenceDevice(), ConnectableDevice, Chargeable { override val address: String get() = "remote://$host" } data object SavedDevice : EvidenceDevice(), ConnectableDevice { override val address: String get() = "saved" } fun connect(device: ConnectableDevice): EvidenceDevice = when (device) { is NearbyDevice -> device is RemoteDevice -> device SavedDevice -> SavedDevice } fun preferred(): ConnectableDevice = NearbyDevice(name = "desk", address = "aa:bb")

The interface keeps its I declaration, and each arm keeps its class base and lists the interface:

public interface IConnectableDevice : IDisposable { string Address { get; } internal NugetKotlinHandle _handle { get; } // internal static IConnectableDevice FromHandle(...) picks the arm class } public sealed class NearbyDevice : EvidenceDevice, IConnectableDevice { /* ... */ } public sealed class RemoteDevice : EvidenceDevice, IConnectableDevice, IChargeable { /* ... */ }
using var nearby = new NearbyDevice("desk", "aa:bb"); using EvidenceDevice connected = Devices.Connect(nearby); // any arm where the interface is expected using IConnectableDevice picked = Devices.Preferred(); // the concrete arm comes back string label = picked switch { NearbyDevice n => $"nearby {n.Address}", RemoteDevice r => $"remote {r.Address}", SavedDevice s => $"saved {s.Address}", _ => throw new InvalidOperationException(), }; bool alsoEvidence = picked is EvidenceDevice; // true

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 data object of a non-generic sealed class

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 object, an enum class, a sub-interface

no

generic, or an arm of a generic sealed class, or the interface is generic

no

abstract, or an intermediate sealed class

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 Pulse to interface IPulse, which breaks C# consumers at compile time.

  • A class that implements two such interfaces, like RemoteDevice above or data class Both : Left, Right, binds as both ILeft and IRight over the one class. It was refused before.

  • Do not implement I<Name> in C#. An ordinary implementation fails to compile (CS0535 on the internal _handle member); 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 Flow method or StateFlow property 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.

sealed interface Marking enum class Patch : Marking { BIB, SOCKS } enum class Swirl(val patch: Patch) : Marking { COCOA(Patch.BIB), CREAM(Patch.SOCKS) }
using Marking painted = EnumArmedSealedSample.PaintedMarking(); string text = painted switch { PatchArm { Value: Patch.Bib } => "bib", PatchArm patch => $"patch {patch.Value}", SwirlArm swirl => $"swirl {swirl.Value} over {swirl.Value.Patch()}", _ => throw new InvalidOperationException(), }; using var socks = new PatchArm(Patch.Socks); // boxes an existing entry

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:

Issue54Shape shape = drawing.Shape; string description = shape switch { Issue54Shape.Circle c => $"Oreo curled at r={c.Radius}", Issue54Shape.Empty => "Mylo sprawled", _ => throw new InvalidOperationException(), };

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:

data object Silence : Transmission data class Ping(val ms: Int, val label: String) : Transmission data class Packet(val signal: Transmission) class Radio { fun heard(signal: Transmission): Int = when (signal) { is Ping -> signal.ms Silence -> 0 } }
using var radio = new Radio(); using var packet = new Packet(radio.History[0]); using Transmission carried = packet.Signal; Assert.Equal(60, Assert.IsType<Ping>(carried).Ms);

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:

sealed class NestedShape { data class Circle(val radius: Double) : NestedShape() } class NestedShapeFactory { fun circle(radius: Double): NestedShape.Circle = NestedShape.Circle(radius) }
NestedShape.Circle unit = factory.Circle(1.0);

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:

sealed class Job { open val kind: String = "job" // renders `public virtual string Kind` on Job itself data class Running(val progress: Int) : Job() { override val kind: String = "running" // renders `override` on Running } data object Idle : Job() // inherits Job's Kind unchanged; declares nothing of its own }
using Job job = JobSample.AnyJob(40); job.Kind; // "running" - dispatches to the concrete arm's own override, or the base's default

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.

interface Squishy { fun squish(factor: Double = 1.0): Double } sealed class Pose : Squishy { override fun squish(factor: Double): Double = factor }
pose.Squish(); // factor defaults to 1.0 pose.Squish(2.0);

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:

sealed interface Shape { suspend fun area(): Int fun ticks(): Flow<Int> val level: StateFlow<Int> suspend fun fallback(): Int = 9 // default body, no arm overrides it data class Loaf(val width: Int) : Shape { /* overrides area, ticks, level */ } data object Donut : Shape { /* overrides area, ticks, level */ } }
public abstract class Shape : IDisposable, IAsyncDisposable { public Task<int> AreaAsync(CancellationToken cancellationToken = default); public KotlinFlow<int> Ticks(); public KotlinStateFlow<int> Level { get; } public Task<int> FallbackAsync(CancellationToken cancellationToken = default); public abstract void Dispose(); public abstract ValueTask DisposeAsync(); }
Shape shape = ShapeSamples.MakeShape(); await using (shape) { int area = await shape.AreaAsync(); await foreach (int tick in shape.Ticks()) { /* ... */ } int level = shape.Level.Value; }

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:

class JobFactory { suspend fun nextLater(progress: Int): Job = when { progress < 0 -> Job.Idle progress < 100 -> Job.Running(progress) else -> Job.Done(progress) } }
using Job result = await factory.NextLaterAsync(7); Assert.Equal(7, Assert.IsType<Job.Running>(result).Progress);

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:

data class Running(val progress: Int) : Job() { fun relabel(transform: (String) -> String): String = transform("running-$progress") }
oreo.Relabel(s => s.ToUpper()); // "RUNNING-9"

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:

sealed class Routine(val label: String) { fun cheer(): (String) -> String = { who -> "$label for $who" } class Tumble(val flips: Int) : Routine("tumble") { fun flip(): (Int) -> String = { extra -> "${flips + extra} flips" } } }
using var tumble = new Routine.Tumble(3); Func<int, string> flip = tumble.Flip(); // "5 flips" for flip(2) Func<string, string> cheer = tumble.Cheer(); // "tumble for Mylo"

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:

using IDisposable sub = oreo.AddTicker(s => ticks.Add(s)); oreo.Tick();

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:

sealed class Torpor { abstract class Dormant : Torpor() { abstract fun depth(): Int open fun label(): String = "deep" } } class DeepTorpor(private val level: Int) : Torpor.Dormant() { override fun depth(): Int = level override fun label(): String = "deeper" } class Den { fun deepest(level: Int): Torpor = DeepTorpor(level) }
using Torpor torpor = den.Deepest(3); var deep = (Torpor.Dormant)torpor; // `torpor is DeepTorpor` is false deep.Depth(); // 3 deep.Label(); // "deeper"

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#:

sealed class Outcome<out T> { data class Ok<T>(val value: T) : Outcome<T>() data class Err(val message: String) : Outcome<Nothing>() data object Loading : Outcome<Nothing>() open fun label(): String = "outcome" } object OutcomeDesk { fun fetch(id: Int): Outcome<Int> = if (id > 0) Outcome.Ok(id) else Outcome.Err("no $id") fun describe(outcome: Outcome<Int>): String { /* ... */ } }
using Outcome<int> outcome = OutcomeDesk.Fetch(3); string text = outcome switch { Outcome.Ok<int> ok => $"ok {ok.Value}", Outcome.Err<int> err => err.Message, Outcome.Loading<int> => "loading", _ => outcome.Label(), }; using var mine = new Outcome.Err<int>("Mylo ate it"); string described = OutcomeDesk.Describe(mine); // "err Mylo ate it"

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> is Duel.Flip<Y, X>.

  • An arm that fixes a covariant (out) parameter gets a placeholder parameter, as Err does.

  • Under an invariant parameter, an arm that closes the argument keeps Kotlin's exact shape: class IntCell : Cell<Int>() is Cell.IntCell : Cell<int>, with no placeholder.

  • An intermediate sealed arm is abstract and has its own arms, and it follows the same rule: sealed class Lapse : Outcome<Nothing>() is Outcome.Lapse<T> with Outcome.Lapse.Stall<T>, while sealed class Spare : Cell<Int>() stays Cell.Spare with Cell.Spare.Last.

  • An arm restates the base's constraints, so a base with T : Dozer gives Claimed<T> where T : IDozer.

  • A type declared beside the arms (Outcome.Detail) lives on the Outcome holder, 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 with ERROR_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 a Flow as the argument).

  • Nothing under a constraint the marker fails: Blanket.Folded is Blanket<Nothing> where C# restates T : IDozer, and KotlinNothing does not implement IDozer.

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

interface Diner { fun onPlated(course: Int) } sealed class Dish<out T> { val served: Flow<Int> = flowOf(1, 2, 3) // declared on the generic base class Hot<T>(val item: T) : Dish<T>() { val portions: Flow<T> = flowOf(item, item) val heat: MutableStateFlow<Int> = MutableStateFlow(60) fun wafts(times: Int): Flow<Int> = flow { repeat(times) { index -> emit(index) } } fun spell(cb: (Char) -> Unit) = item.toString().forEach(cb) fun addBell(listener: (Int) -> Unit) { /* ... */ } fun removeBell(listener: (Int) -> Unit) { /* ... */ } fun addDiner(listener: Diner) { /* ... */ } fun removeDiner(listener: Diner) { /* ... */ } } class Bare(val crumbs: Int) : Dish<Nothing>() { val drips: StateFlow<Int> = MutableStateFlow(crumbs) suspend fun wipe(): Int = crumbs } }
public static class Dish { public sealed class Hot<T> : Dish<T>, IAsyncDisposable { public KotlinFlow<T> Portions { get; } public KotlinMutableStateFlow<int> Heat { get; } public KotlinFlow<int> Served { get; } public KotlinFlow<int> Wafts(int times); public void Spell(Action<char> cb); public IDisposable AddBell(Action<int> listener); public IDisposable AddDiner(IDiner listener); } }
await using var oreo = new Dish.Hot<string>("tuna"); await foreach (int course in oreo.Served) { /* 1, 2, 3 */ } await foreach (string portion in oreo.Portions) { /* "tuna", "tuna" */ } oreo.Heat.Value = 45; IDisposable bell = oreo.AddBell(course => Console.WriteLine(course)); bell.Dispose(); // stops the callback

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 throws NotSupportedException the first time. The build names the member on one SKIPPED_UNIMPLEMENTABLE_INTERFACE warning 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 object members (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 an object.

  • 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, an enum class beside 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 class arm's boxed constructor and Value getter cost a handle and a P/Invoke each; see An enum class arm for the disposal obligation and the missing implicit conversion.

  • An object arm, or a class-kind arm whose every constructor is refused, has only the internal handle constructor, so new Base.Arm(9) fails to compile rather than binding it; obtain the arm from a factory or from the base's FromHandle discriminator instead. A class-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, Nothing under 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 suspend lambda parameter binds on an arm as well (not on an arm with its own Kotlin type parameter, which skips any suspend member, see Members of a generic arm), as does a Flow<T> parameter of a primitive or String, suspend or not; see A suspend lambda as a parameter and A Flow as a parameter.

  • A member whose C# name equals a type nested in the sealed base, or another arm's name (backing() beside a nested class Backing), is a named ERROR_CSHARP_NAME_COLLISION at 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 Kotlin object or interface member named like its own type is not checked, so it surfaces as a C# error in your build instead.

Last modified: 03 November 2026