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# |
|---|---|
|
|
constructor |
|
| property (get / get+set) |
nested | nested C# type, see Nested types |
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.
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.
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.
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.
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:
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:
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.
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.
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.
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:
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.
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:
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.
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:
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:
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.
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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:
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>:
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.
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:
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>:
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:
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/Setare not yet supported as method or constructor parameters; see Collections.An
expect-side default reaches every method,object, companion and extension member of anexpect/actualclass, 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_DECLARATIONskip, as is every descendant of a nestedsealed class(it is not a declared owner). A member typed with a nestedvalue classunder a still-deferred owner (anenum classor a genericinterface) skips named too (SKIPPED_UNSUPPORTED_TYPE, reasonUNDECLARED_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 nestedvalue classwhose owner sits in a dependency module outside the export scope skips named too (SKIPPED_UNEXPORTED_DEPENDENCY_TYPE, orSKIPPED_UNSUPPORTED_PROPERTYon a property); the message points atadmit("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.