kotlin-native-nuget Help

Classes and objects

A Kotlin class becomes a C# class backed by an opaque StableRef handle, implementing IDisposable. Constructors, member properties (including setters), and object-typed properties/returns all cross the bridge through that handle.

Kotlin

C#

Notes

class

class : IDisposable

StableRef + opaque pointer

constructor

new Foo(...)

Kotlin constructor surfaces as a C# new

member property (get)

property (get)

member property (get/set)

property (get/set)

object-typed property/return

property/return

new wrapper per access, identity not preserved

instance method return (object, T?, List/Map/Set, enum, Char, String?, Int?, …)

matching C# return type

same cascade as the property getter via the shared plan (ADR-062); nullable numeric is single-call valueOut, see Method returns below

Kotlin

From test-library/src/nativeMain/kotlin/.../cat/Cat.kt:

class Cat( name: String, val lives: Int = 9, ) : Animal(name) { var brother: Cat? = null var owner: String? = null var age: Int? = null var mood: Mood = Mood.SLEEPY override fun speak(): String = "Meow! My name is $name" fun meow(): String = "Meow! My name is $name" fun pet(): String = "$name purrs contentedly" }

Generated C#

From Interop.cs. The constructor allocates a StableRef and stores the handle; Brother shows the object-typed property pattern, Owner shows a nullable string property with a setter:

public class Cat : Animal { [DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "cat_create")] private static extern IntPtr Native_Create(string name, int lives, out IntPtr error); public Cat(string name, int lives) : base(IntPtr.Zero) { IntPtr handle = Native_Create(name, lives, out IntPtr error); if (error != IntPtr.Zero) { throw NugetErrorNative.BuildException(error); } _handle = handle; } internal Cat(IntPtr handle) : base(handle) { } [DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "cat_get_brother")] private static extern IntPtr Native_Get_brother(IntPtr handle, out IntPtr error); [DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "cat_set_brother")] private static extern void Native_Set_brother(IntPtr handle, IntPtr value, out IntPtr error); public Cat? Brother { get { IntPtr nativeResult = Native_Get_brother(_handle, out IntPtr error); if (error != IntPtr.Zero) { throw NugetErrorNative.BuildException(error); } return nativeResult == IntPtr.Zero ? null : new Cat(nativeResult); } set { Native_Set_brother(_handle, value?._handle ?? IntPtr.Zero, out IntPtr error); if (error != IntPtr.Zero) { throw NugetErrorNative.BuildException(error); } } } }

Every access to Brother calls Native_Get_brother again and wraps the resulting handle in a new Cat instance. There is no caching. Setting Brother unwraps the C# wrapper's _handle field (or passes IntPtr.Zero for null).

Using it from C#

From IntegrationTests/ObjectTests.cs:

[Fact] public void Cat_Brother_EachAccessReturnsNewWrapper() { 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; // Per ADR-005: identity is NOT preserved (new wrapper each access) Assert.NotSame(brother1, brother2); Assert.Equal(brother1!.Name, brother2!.Name); } [Fact] public void Cat_Brother_DisposingBrotherDoesNotAffectOriginal() { using var oreo = new Cat("Oreo", 9); using var mylo = new Cat("Mylo", 9); oreo.Brother = mylo; Cat? brother = oreo.Brother; brother!.Dispose(); // Oreo is still alive — disposing the brother wrapper only releases that one StableRef Assert.Equal("Oreo", oreo.Name); } [Fact] public void Cat_Brother_CyclicReference_BothCanBeDisposed() { using var oreo = new Cat("Oreo", 9); using var mylo = new Cat("Mylo", 9); oreo.Brother = mylo; mylo.Brother = oreo; using Cat? oreosBrother = oreo.Brother; using Cat? mylosBrother = mylo.Brother; // All wrappers can be independently disposed without crashes }

From IntegrationTests/NullablePropertyTests.cs, a nullable primitive property:

[Fact] public void Cat_Age_SetToNull() { using var cat = new Cat("Oreo", 9); cat.Age = 3; Assert.Equal(3, cat.Age); cat.Age = null; Assert.Null(cat.Age); }

Disposal semantics

Disposal does not cascade. A parent wrapper's Dispose() only releases its own StableRef; any wrapper obtained from a property or method call on that parent holds an independent StableRef and must be disposed separately (or leaks). This is deliberate: since every access allocates a new wrapper, there's no tree of ownership for a parent to walk. See ADR-005 for the alternative designs considered (cached wrapper with cascading dispose was rejected).

Method returns

An instance method returning an object, a collection (List/Map/Set and mutable variants), an enum, a Char, or a nullable type crosses the bridge through the same planned marshalling path the property getter uses (ADR-061, ADR-062). The one exception is a nullable numeric return: a method might have side effects, so it can't reuse the property getter's two-call hasValue/value pattern (that would invoke the method twice). It gets a single-call shape instead: the export returns bool (has-value) and writes the value through a valueOut out-parameter.

From test-library/src/nativeMain/kotlin/.../cat/Cat.kt:

/** Object return (converting: handle -> `new Cat`). No brother set -> a cat looks after itself. */ fun findOwner(): Cat = brother ?: this /** Nullable object return. Null until `brother` is assigned. */ fun maybeOwner(): Cat? = brother /** Collection return, converting element (String needs marshalling per element). */ fun tags(): List<String> = listOf("$name-tag", "$name-chip") /** Collection return, non-converting element (Int is blittable). */ fun scores(): List<Int> = listOf(lives, lives * 2) /** Nullable String return, single-call. Null until `owner` is assigned. */ fun alias(): String? = owner?.let { "$name (owned by $it)" } /** Nullable primitive return, single-call out-param per ADR-061. Null until `age` is assigned. */ fun ageInMonths(): Int? = age?.times(12)

Generated C#, from Interop.cs (the collection return, Tags()/Scores(), walks the handle the same way as a list property, see Collections):

public Cat FindOwner() { IntPtr nativeResult = Native_FindOwner(_handle, out IntPtr error); if (error != IntPtr.Zero) { throw NugetErrorNative.BuildException(error); } return new Cat(nativeResult); } public Cat? MaybeOwner() { IntPtr nativeResult = Native_MaybeOwner(_handle, out IntPtr error); if (error != IntPtr.Zero) { throw NugetErrorNative.BuildException(error); } return nativeResult == IntPtr.Zero ? null : new Cat(nativeResult); } public string? Alias() { IntPtr nativeResult = Native_Alias(_handle, out IntPtr error); if (error != IntPtr.Zero) { throw NugetErrorNative.BuildException(error); } return Marshal.PtrToStringUTF8(nativeResult); } [DllImport("test", CallingConvention = CallingConvention.Cdecl, EntryPoint = "cat_ageInMonths")] private static extern bool Native_AgeInMonths(IntPtr handle, out int value, out IntPtr error); public int? AgeInMonths() { bool hasValue = Native_AgeInMonths(_handle, out int value, out IntPtr error); if (error != IntPtr.Zero) { throw NugetErrorNative.BuildException(error); } return hasValue ? value : null; }

From IntegrationTests/MethodReturnMarshallingTests.cs:

[Fact] public void Cat_FindOwner_ReturnsBrotherWhenSet() { using var oreo = new Cat("Oreo", 9); using var mylo = new Cat("Mylo", 8); oreo.Brother = mylo; using Cat owner = oreo.FindOwner(); Assert.Equal("Mylo", owner.Name); } [Fact] public void Cat_AgeInMonths_NonNullWhenAgeSet() { using var oreo = new Cat("Oreo", 9); oreo.Age = 3; Assert.Equal(36, oreo.AgeInMonths()); }

The same cascade applies at the extension-function position; see Extensions. Enum, Char, and Map/Set method returns are covered under the shared plan; clinic fixtures Patient.Mood(), Patient.Initial(), Patient.Scores(), and Patient.Labels() exercise them (see Enums, Primitives and strings, Collections).

Classes declared in a dependency module

A class doesn't need to be declared in the publishing Gradle module to reach the generated C# API. The export set is a reachability closure (ADR-066): starting from the module's own exported declarations, the processor walks return types, parameter types, and property types, and admits a discovered type declared in a dependency module (pulled in with implementation(project(":models"))) through the same include/exclude/rootPackage predicate used for the module's own files. See The nuget {} DSL for the full rule.

From test-library/src/nativeMain/kotlin/.../Newsroom.kt, where TopStory and Byline are declared one Gradle module away, in :test-models:

// io.github.xxfast.kotlin.native.nuget.test.models, in :test-models data class TopStory(val title: String, val rank: Int, val byline: Byline?) class Byline(val name: String)
// io.github.xxfast.kotlin.native.nuget.test, in :test-library class Newsroom { fun latest(): TopStory = TopStory("Oreo escapes the cardboard box (again)", 1, Byline("Mylo")) }

Byline is never returned directly by anything in :test-library; it only enters the export set because TopStory.byline references it, which is what proves the closure keeps walking rather than stopping after one hop. Both classes generate exactly like a module-local class, under a namespace derived from their own Kotlin package (TestLibrary.Models), not the exporting module's:

namespace TestLibrary.Models { public class TopStory : IDisposable { public string Title { get; } public int Rank { get; } public global::TestLibrary.Models.Byline? Byline { get; } // ... } public class Byline : IDisposable { public string Name { get; } // ... } }

From IntegrationTests/NewsroomReachabilityTests.cs:

[Fact] public void Latest_Byline_ReachableOnlyTransitively_ThroughAnAlreadyAdmittedType() { using var newsroom = new Newsroom(); using TopStory story = newsroom.Latest(); using Byline? byline = story.Byline; Assert.NotNull(byline); Assert.Equal("Mylo", byline!.Name); }

A dependency-module type reached this way is otherwise a completely ordinary handle-backed class: cyclic references (Whisker.purr: Purr?/Purr.whisker: Whisker?) resolve correctly and the closure terminates rather than recursing forever, and a type whose package falls outside the effective include/rootPackage scope is skipped with a named diagnostic instead of silently binding or breaking the build; see Publishing Kotlin to C#.

Limitations

  • Nullable Boolean method returns remain unplanned: the callable is omitted and a SKIPPED_UNSUPPORTED_RETURN diagnostic names it, rather than the fallthrough-emit this used to be (ADR-062, ADR-064).

  • Map/Set inputs (parameters) are not planned yet; see Collections.

Last modified: 21 July 2026