kotlin-native-nuget Help

Classes and objects

A Kotlin class becomes a C# class implementing IDisposable. Each instance wraps an opaque native handle: dispose it when you are done, and every constructor call, property access, and method call crosses the bridge through that handle.

Kotlin

C#

class

class : IDisposable

constructor

new Foo(...)

val/var property

property (get / get+set)

nested class/object/interface/enum class/value class/inner class

nested C# type, see Nested types

class Cat( name: String, val lives: Int = 9, ) : Animal(name) { var brother: Cat? = null var owner: String? = null var age: Int? = null }
using var oreo = new Cat("Oreo", 9); using var mylo = new Cat("Mylo", 9); oreo.Brother = mylo; using Cat? brother1 = oreo.Brother; using Cat? brother2 = oreo.Brother; Assert.NotSame(brother1, brother2); // a fresh wrapper on every access Assert.Equal(brother1!.Name, brother2!.Name);

A setter narrower than public binds get-only

A var whose setter is narrower than the property itself (private set, protected set, internal set) becomes a get-only C# property: it reads like a val from C#, and Kotlin's own mutators still change the value underneath it. This holds even for internal set, since a generated C# consumer is never inside the Kotlin module the modifier scopes access to.

class Button(val label: String = "ok") { var clicks: Int = 0 private set fun click(): String { clicks++; return "$label clicked $clicks" } }
public int Clicks { get; } // no set accessor; button.Clicks = 5 is CS0200

An override that widens the setter back toward public in Kotlin cannot follow in C#: the base property is already get-only, and an override cannot add a set accessor a base property does not have (CS0546), so the override drops its setter too, with a warning explaining why.

Object identity and disposal

An object-typed property or method return mints a new C# wrapper on every access; there is no identity caching, so reading oreo.Brother twice gives wrappers that are NotSame but Equal. Disposal does not cascade: a wrapper's Dispose() releases only its own handle. Disposing a Brother wrapper you read off oreo does not affect oreo, and does not affect any other wrapper read from the same property. Dispose every wrapper you hold, including ones you only read a property or method return into.

Dispose a wrapper to release its Kotlin object promptly. A wrapper you drop without disposing is released when the .NET GC finalizes it: eventually, on the finalizer thread, and not at process exit. Dispose() is idempotent and safe to call from any thread. A suspend, Flow or StateFlow call in flight keeps what it needs alive until it completes, so await new Desk().SettleAsync(5) needs no local for the desk.

One exception: the IDisposable an AddX stored-callback subscription returns is not released by its own finalizer. Dropping it leaves the listener registered and delivering while the owner lives; disposing or finalizing the owner unsubscribes it, and a later Dispose() of the subscription does nothing.

A held or awaited KotlinStateFlow used after its own Dispose(), or collected after the object it came from was disposed, throws ObjectDisposedException.

Method returns

An instance method returning an object, a nullable type, a collection, an enum, or Char marshals the same way a property of that type would (see Collections and Enums). A method returning a nullable primitive, including Boolean?, evaluates only once: unlike a property getter it may have side effects, so it can't be called twice to fetch a has-value flag and then the value.

fun ageInMonths(): Int? = age?.times(12)
oreo.Age = 3; Assert.Equal(36, oreo.AgeInMonths());

A method typed as an abstract class returns an internal subclass that forwards to the Kotlin object; see An abstract class as a return type.

A nullable class handle parameter

A parameter typed with a nullable exported class (Foo?), on a constructor, method, extension, or top-level function, binds as an ordinary nullable handle: null needs no separate has-value flag, since a handle already has its own null. The handle is borrowed, not consumed: the same instance can back several calls and stays usable afterwards.

class Hub(val settings: Settings, val logger: Logger?, val note: String? = null) { fun describe(): String = "${settings.level}/${logger?.tag ?: "none"}/${note ?: "-"}" }
using var logger = new Logger("Oreo"); using var hub = new Hub(settings, logger, null); Assert.Equal("[Oreo] still here", logger.Log("still here")); // logger is still usable

A nullable return-shaped type at a parameter (Flow<Event>?) is a different case; see Publishing Kotlin to C#.

Method overloads

Two or more same-named methods on an exported class collapse into one natural C# overload set; call whichever one matches your argument types.

class CatNarrator(val name: String) { fun describe(): String = "$name is a cat" fun describe(prefix: String): String = "$prefix $name" fun rate(stars: Int): String = "$name rated $stars" fun rate(mood: Mood): String = "$name is ${mood.name.lowercase()}" }
using var narrator = new CatNarrator("Oreo"); Assert.Equal("Oreo is a cat", narrator.Describe()); Assert.Equal("the biscuit cat Oreo", narrator.Describe("the biscuit cat")); Assert.Equal("Mylo is grumpy", narrator.Rate(Mood.Grumpy));

C# cannot overload on reference nullability alone: a pair like fun tag(s: String) and fun tag(s: String?) would render two identical C# signatures, so generation fails outright (ERROR_CSHARP_SIGNATURE_COLLISION) instead of producing invalid C#. Give one of them a distinct name.

Property and method name collisions

Kotlin keeps properties and functions in separate namespaces; C# does not. A property and a method that render the same C# name on one class fail generation with ERROR_CSHARP_NAME_COLLISION (CS0102), naming both Kotlin declarations, rather than producing C# that cannot compile:

class Counter(val start: Int) { val count: Int get() = start fun count(): Int = start + 1 }

Renaming either count fixes it, or put @CSharpName on one to give just its C# member another name (see Choosing the C# name). There is no automatic rename, since either direction would silently change the API. Casing counts too: val size beside fun Size() collides once both PascalCase to Size, and a const val collides with an ordinary property or method the same way. The same check runs on every C# type a Kotlin declaration can render into: a class and its companion together (they share one C# type), a sealed base and each of its arms, a value class's own underlying property, and a generic class.

It also catches the shape where a subclass declares a member that takes the C# name of an inherited member of the other kind, instead of colliding with it directly:

open class Parcel<T>(val value: T) class NamedParcel(name: String) : Parcel<String>(name) { fun value(prefix: String): String = prefix }

NamedParcel.Value(string) would hide the inherited Parcel<string>.Value property (C#'s CS0108), and a caller typed as NamedParcel could never read it again (n.Value resolves to the method, a CS0428 compile error); only an upcast to Parcel<string> would. This fails generation the same way, naming the declaring class, the base, and both Kotlin declarations; rename the derived member instead. The reverse direction (a declared property hiding an inherited method) fails for the same reason: C# still reports CS0108, which fails a consumer build under TreatWarningsAsErrors, even though the method stays callable through the derived type.

A property beside an unrelated, unbridgeable method of the same Kotlin name is unaffected: only one of the two ever reaches C#, so nothing collides.

A class's own interface beside a kept base class

A class with an exported base class keeps its own exported interfaces too: class X : Base(), IFoo renders public class X : Base, IFoo, not Base alone with IFoo dropped. See Interfaces, abstract classes, and sealed classes for when a base class or interface is dropped instead of kept.

When one .NET project references several Kotlin-built packages, each package's generated C# wrappers use its own native runtime and handle state. Dispose each wrapper as usual; the packages do not share Kotlin object handles.

Constructor and method default parameters

A Kotlin defaulted parameter binds as one C# signature: a non-nullable type widens to its nullable C# form, and null means "use the Kotlin default". A parameter that is already nullable in Kotlin widens to the shared KotlinOptional<T> struct instead, since null there is already a real value; default means unset. Whichever way it widens, any subset of the defaulted parameters can be set by name in one call, and Kotlin evaluates the rest.

data class Config( val id: Uuid = Uuid.random(), val retries: Int = 3, val mode: Mode = Mode.Never, ) object Registry { fun describe(name: String, owner: String? = "nobody"): String = "..." }
new Config(mode: Mode.Always); // id and retries: Kotlin evaluates Uuid.random() and 3 Registry.Describe("Momo"); // owner unset -> Kotlin's "nobody" Registry.Describe("Momo", owner: null); // owner set to null, distinct from unset

A defaulted parameter followed by a required one (a middle default) can't be widened to optional, since C# can't skip over a required positional argument either: it stays required-but-nullable instead of optional.

class Book(val title: String, val pages: Int = 100, val city: String)
new Book("Paws", null, "Colombo"); // pages must be passed, but null still means "use 100"

Copy on a data class gets the same widened shape, so a partial copy works: an omitted parameter keeps the receiver's current value, which is copy's own Kotlin default.

original.Copy(mode: Mode.Always); // every other field is the receiver's own value

The rule is the same for class constructors, class methods, object /companion members, top-level functions, extension functions, and interface methods; see Objects and companions, Top-level declarations, Extensions, and Interfaces, abstract classes, and sealed classes. An override widens with the same C# signature as its root overridee, so a base and derived class agree without restating the default:

open class Base { open fun rate(score: Int = 5): Int = score } class Derived : Base() { override fun rate(score: Int): Int = score * 10 }
new Derived().Rate(); // score defaults to 5, the base's default, then multiplied by 10

An exported Kotlin interface's defaulted member widens the same way, so the interface and every implementer's C# signature agree; there is no separate rule for members reached through an interface.

A defaulted parameter the bridge cannot route to any non-null C# form at all (a nullable Flow<T>?, a sealed type, or a bound interface) is dropped from the C# signature entirely when it is part of the trailing all-defaulted run, and Kotlin always evaluates its default; call the shorter C# signature. One that sits before a required parameter leaves the whole callable unroutable, same as an unsupported required parameter would.

class Desk(val name: String) { fun open(settings: Settings = Settings(), events: Flow<Int>? = null): String = "..." }
using var desk = new Desk("Oreo"); desk.Open(); desk.Open(settings); // settings still widens; `events` never reaches C#

A defaulted lambda parameter (a (T) -> R used only for the duration of the call, never stored) widens to its nullable delegate form the same way as any other type:

fun notify(message: String, onDone: (String) -> Unit = {}): String { onDone(message); return "sent $message" }
Issue297Sample.Notify("meow", onDone: message => heard = message); Issue297Sample.Notify("meow"); // onDone unset: Kotlin's no-op default runs

A lambda parameter a constructor stores past the call (assigned to a property, called later) never reaches C# at all, widened or not: a per-call native handle for it cannot outlive the constructor call, so the class keeps only its non-lambda constructor and Kotlin's default always runs for the stored one.

class Button(val label: String = "ok", val onClick: () -> Unit = {})
new Button("go"); // the only constructor; onClick is always Kotlin's no-op default

A defaulted parameter whose omission would exactly match a real, shorter overload you declared yourself stays required in C#, so widening it can't quietly steal calls meant for that overload:

class Kitten(val name: String, val lives: Int = 9) { constructor(name: String) : this(name, 1) // the real, shorter overload }
new Kitten("Oreo"); // reaches Kitten(name); lives is 1, not the widened default 9 new Kitten("Oreo", lives: 9); // reaches the widened constructor explicitly

Copy is exempt from this rule: a data class's copy has no user-declared sibling to shadow.

At most 8 parameters per callable can widen. Past that cap, only the last 8 in declaration order widen and the earlier ones stay required; a WARNING_DEFAULT_PARAMETER_CAP_EXCEEDED diagnostic names the parameters left required, so split the callable or pass them explicitly.

Generated C#

A widened non-nullable parameter renders as = null; a widened already-nullable one renders as KotlinOptional<T> = default:

public Config(Guid? id = null, int? retries = null, Mode? mode = null) public string Describe(string name, KotlinOptional<string?> owner = default)

A signature that would otherwise collide with another constructor still fails generation with ERROR_CSHARP_SIGNATURE_COLLISION, naming the defaulted parameter as the cause.

No public constructor

A class whose every public constructor is skipped, for example because a constructor parameter has an opt-in-marked or otherwise unsupported type, is still exported: it just has no public C# constructor, only the internal one every handle class carries. C# code obtains an instance from a Kotlin factory function or property that returns one. The build warns, naming the skipped constructor and the reason, and the same detail appears as an XML <remarks> comment on the generated class, so it shows up as an IDE tooltip too.

@OptIn(CatteryInternalApi::class) data class GroomingPlan( val name: String = "Oreo", val grooming: Grooming = Grooming.DAILY, // Grooming is opt-in-marked ) @OptIn(CatteryInternalApi::class) fun plan(): GroomingPlan = GroomingPlan()
/// <remarks> /// Cannot be constructed from C#: every Kotlin constructor of GroomingPlan was skipped by the /// bridge (&lt;init&gt;: OPT_IN_MARKER_TYPE). Instances come from Kotlin factories that return /// this type. /// </remarks> public class GroomingPlan : IDisposable, INugetHandle { internal GroomingPlan(IntPtr handle, out NugetHandleTag tag) { tag = default; _handle = handle; } } using GroomingPlan plan = Issue128Sample.Plan(); // the only way to obtain one

Classes declared in a dependency module

A class does not need to be declared in the publishing Gradle module to reach the generated C# API. Starting from the module's own exported declarations, the processor also walks return, parameter, and property types, and admits any type it discovers in a dependency module, under that type's own Kotlin package rather than the exporting module's. See The nuget {} DSL for the include/exclude rule this follows.

Nested types

A public class, object, interface, or enum class, at any depth, declared inside a class, object, interface, or sealed base/arm, becomes a real C# nested type, Outer.Nested. A generic class declares them on a static holder of the same name, so the spelling holds; see Nested types and generic classes.

Kotlin nested kind

C# shape

class

Outer.Nested : IDisposable, same members as a top-level class

object

public static class Outer.Defaults, statics only

enum class

public enum Outer.Kind; its extension methods go on a top-level OuterKindExtensions class, since C# forbids extension methods inside a nested class

interface

public interface Outer.IListener, with its backing wrapper class nested beside it as Outer.Listener

value class

public readonly record struct Outer.Tag, under any admitted owner

inner class

Outer.Nested : IDisposable, constructor takes the outer instance first, see Inner classes

class Aviary(val name: String) { class Perch(val height: Int) { fun describe(): String = "perch@$height" } fun perchAt(height: Int): Perch = Perch(height) fun heightOf(perch: Perch): Int = perch.height }
using var aviary = new Aviary("Oreo"); using var perch = aviary.PerchAt(5); Assert.Equal("perch@5", perch.Describe()); Assert.Equal(5, aviary.HeightOf(perch));

An enum class owner stays a named skip (SKIPPED_NESTED_DECLARATION); see Inner classes for what an inner class declares. Kotlin allows a nested type named exactly like its owner, or like a member of its owner (a companion's members included, since they fold into the owner's C# type as statics); C# does not, so that combination fails generation (ERROR_CSHARP_SIGNATURE_COLLISION) instead of emitting invalid C#. The member is compared by the name it has in C#: its PascalCased name, the name from @CSharpName, or {Name}Async for a suspend fun. So @CSharpName("Perch") fun take() beside class Perch fails, while suspend fun perch() or @CSharpName("perch") fun perch() beside it does not. Avoid naming an accessor, or a companion function, the same as its nested return type (fun perch(): Perch); name it differently instead (perchAt).

Nested types and generic classes

A generic class nested in a non-generic owner is Outer.Inner<T>, constructed like any generic class:

class Tote(val owner: String) { class Purse<T>(val item: T) }
using var purse = new Tote.Purse<string>("Oreo");

A type nested in a generic class is different. C# makes every type inside Teapot<T> a separate type per T, while Kotlin has one Teapot.Lid, so everything nested in a generic class is declared on a non-generic static class with the owner's name, beside Teapot<T>:

class Teapot<T>(val item: T) { class Lid(val number: Int) object Defaults { const val CUPS: Int = 3 } class Cozy<U>(val pattern: U) fun lidAt(number: Int): Lid = Lid(number) } object TeaShop { fun spare(): Teapot.Lid = Teapot.Lid(9) }
using var lid = new Teapot.Lid(7); int cups = Teapot.Defaults.Cups; using var cozy = new Teapot.Cozy<string>("Mylo"); using var teapot = new Teapot<int>(5); using Teapot.Lid own = teapot.LidAt(1); using Teapot.Lid spare = TeaShop.Spare(); // the same Teapot.Lid

Teapot<int>.Lid does not compile; write Teapot.Lid. A class, object, enum class, interface, value class or generic class nested in a generic class all go there, and the holder exists only when at least one of them is declared. Because the members live on Teapot<T> and the nested types on the holder, a member named like a nested type (val lid) is not a collision; a nested type named like its generic owner still is (ERROR_CSHARP_SIGNATURE_COLLISION, CS0542).

inner class: the constructor takes the outer instance first

A public Kotlin inner class declared directly inside an admitted class becomes a nested C# type too, with one difference from a plain nested class: its constructor's first parameter is the outer instance, named outer.

class Hearth(val room: String) { inner class Sunbather(val minutes: Int) { val basking: String get() = "${this@Hearth.room} warms Oreo for $minutes min" } }
using var hearth = new Hearth("The bay window"); using var sunbather = new Hearth.Sunbather(hearth, 3); Assert.Equal("The bay window warms Oreo for 3 min", sunbather.Basking);

Disposing hearth before sunbather is safe: the inner instance's own reference to its outer keeps the Kotlin object alive, so sunbather.Basking keeps reading it after hearth.Dispose() runs. A declared constructor parameter literally named outer renders as outer_ in both the generated C# signature and the Kotlin export instead (the same shift value already gets on a property setter); a member reading this@Hearth needs nothing added at the ABI, since the reference lives entirely on the Kotlin heap.

An inner class may itself own inner classes, and so on, each taking its nearest outer instance first, and an inner class under a sealed base or arm works the same way:

using var sunbather = new Hearth.Sunbather(hearth, 3); using var paw = new Hearth.Sunbather.Paw(sunbather, 2);

A generic inner class takes its type argument after the outer (new Tote.Charm<int>(tote, 7)). An inner class of a generic owner captures T, so it is declared on the owner's holder with the captured parameters first, and its outer is typed Teapot<T>:

class Teapot<T>(val item: T) { inner class Strainer(val mesh: Int) { fun peek(): T = item } inner class Infuser<U>(val leaf: U) { fun both(): String = "$item/$leaf" } }
using var teapot = new Teapot<string>("Oreo"); using var strainer = new Teapot.Strainer<string>(teapot, 2); string item = strainer.Peek(); // "Oreo" using var infuser = new Teapot.Infuser<string, int>(teapot, 4);

Still a named skip (SKIPPED_NESTED_DECLARATION): a class nested in an inner class of a generic owner, an inner class whose own type parameter reuses a captured name (inner class Echo<T> in Teapot<T>), an inner class of a multi-bound owner (class Arena<T> where T : Comparable<T>, T : Pet; its non-inner children still bind on the holder), and anything nested in a generic interface or an enum class. A member that returns an inner class of a generic owner is skipped named at that member. The hint for a skipped inner class is to drop inner, take the outer instance as a constructor parameter, and move it to the top level of its file.

A @Serializable class exports the same as any other class. kotlinx.serialization's compiler-generated $serializer nested object is never declared in C#, since $ isn't a legal C# identifier there; it has no members you'd want to call, so skipping it costs nothing. Nothing about it appears in the build log either: a member the compiler wrote, a hidden-deprecated member or the serialization plugin's serializer(), is never reported as skipped.

An object at a member position stays CS0722

A Kotlin object, nested or top-level, renders as a C# static class, and C# forbids a static type at a parameter or return position (CS0722). A member typed with one is skipped rather than emitted as uncompilable C# (SKIPPED_UNSUPPORTED_TYPE); the object type itself is still declared, so call its members directly instead of routing through the skipped member.

Owners ADR-134 admits

An interface owner, a sealed base/arm owner, and a nested value class candidate under any admitted owner all declare nested types too:

interface Cage { class Bar(val n: Int) { fun describe(): String = "bar#$n" } fun barAt(): Bar } sealed class Purr { data class On(val level: Int) : Purr() { class Trace(val at: Int) { fun describe(): String = "trace@$at" } fun traceOf(): Trace = Trace(level) } }
ICage cage = new WireCage(7); using ICage.Bar bar = cage.BarAt(); Assert.Equal(7, bar.N); using Purr purr = Deferred.PurringPurr(9); Purr.On on = Assert.IsType<Purr.On>(purr); using Purr.On.Trace trace = on.TraceOf();

The owner-name collision above is checked against the generated C# name, not the Kotlin one: an interface owner gets an I prefix in C#, so a nested type sharing its interface owner's Kotlin name is legal (interface Cage { class Cage } declares ICage.Cage, two different C# names, not a collision).

A sealed arm's internal Arm(IntPtr handle, out NugetHandleTag tag) constructor still exists beside any exported public one; the trailing out parameter means it is never a candidate for an ordinary call. Purr.On's own level: Int constructor parameter is bridgeable, so On also exports a public constructor, and new Purr.On(9) always resolves to it; see Interfaces, abstract classes, and sealed classes: Sealed classes and interfaces for the general rule.

Limitations

  • Map/Set are not yet supported as method or constructor parameters; see Collections.

  • An expect-side default reaches every method, object, companion and extension member of an expect/actual class, and its primary constructor; a secondary constructor still binds that parameter as required. See expect/actual declarations.

  • A nested type under an owner other than the shapes covered above stays a named SKIPPED_NESTED_DECLARATION skip, as is every descendant of a nested sealed class (it is not a declared owner). A member typed with a nested value class under a still-deferred owner (an enum class or a generic interface) skips named too (SKIPPED_UNSUPPORTED_TYPE, reason UNDECLARED_VALUE_CLASS) instead of emitting an unusable struct name; move the value class to the top level of its file, or to an admitted owner, to bridge it. A nested value class whose owner sits in a dependency module outside the export scope skips named too (SKIPPED_UNEXPORTED_DEPENDENCY_TYPE, or SKIPPED_UNSUPPORTED_PROPERTY on a property); the message points at admit("pkg.Owner") for the owner. An extension function or property on a nested type is not supported; declare the extension on a top-level type instead.

Last modified: 03 November 2026