Collections
Kotlin's collection types cross the bridge as an opaque handle plus a small accessor surface (Count/Get/ContainsKey/...), then get eagerly copied into a real .NET collection on the C# side. There's no lazy bridging or lasting connection between the returned C# collection and the Kotlin one after the copy.
Kotlin | C# | Notes |
|---|
List<T>
| IReadOnlyList<T>
| eager copy via opaque handle |
MutableList<T>
| IList<T>
| eager copy |
Map<K,V>
| IReadOnlyDictionary<K,V>
| eager copy |
MutableMap<K,V>
| IDictionary<K,V>
| eager copy |
Set<T>
| IReadOnlySet<T>
| eager copy |
MutableSet<T>
| ISet<T>
| eager copy |
Kotlin
From test-library/src/nativeMain/kotlin/.../cat/Cat.kt:
val nicknames: List<String> = listOf("${name}y", "Little $name")
val toys: List<Toy> = listOf(Toy("Mouse", "Gray"), Toy("Ball", "Red"))
val favoriteFoods: MutableList<String> = mutableListOf("Tuna", "Salmon")
val accessories: Map<String, Toy> = mapOf(
"collar" to Toy("Bell Collar", "Gold"),
"tag" to Toy("Name Tag", "Silver"),
)
val traits: Set<String> = setOf("Playful", "Curious", "Fluffy")
val vaccinations: MutableSet<String> = mutableSetOf("Rabies", "FVRCP")
val schedule: MutableMap<String, String> = mutableMapOf(
"morning" to "Nap",
"evening" to "Play",
)
Generated C#
Every collection-typed property follows the same shape: fetch an opaque listHandle/mapHandle/setHandle from Kotlin, walk it with Count/Get/KeyAt/ValueAt/ElementAt, copy into a real .NET collection, then dispose the Kotlin-side handle.
public IReadOnlyList<string> Nicknames
{
get
{
IntPtr listHandle = Native_Get_nicknames(_handle, out IntPtr error);
if (error != IntPtr.Zero) throw NugetErrorNative.BuildException(error);
int count = NugetListNative.Count(listHandle);
var result = new List<string>(count);
for (int i = 0; i < count; i++)
{
result.Add(NugetMarshal.FromHandle<string>(NugetListNative.Get(listHandle, i)));
}
NugetListNative.Dispose(listHandle);
return result.AsReadOnly();
}
}
public IList<string> FavoriteFoods
{
get
{
/* same walk as Nicknames, but no .AsReadOnly(), returns the mutable List<string> directly */
}
}
public IReadOnlyDictionary<string, Toy> Accessories
{
get
{
IntPtr mapHandle = Native_Get_accessories(_handle, out IntPtr error);
if (error != IntPtr.Zero) throw NugetErrorNative.BuildException(error);
int count = NugetMapNative.Count(mapHandle);
var result = new Dictionary<string, Toy>(count);
for (int i = 0; i < count; i++)
{
var key = NugetMarshal.FromHandle<string>(NugetMapNative.KeyAt(mapHandle, i));
var value = NugetMarshal.FromHandle<Toy>(NugetMapNative.ValueAt(mapHandle, i));
result[key] = value;
}
NugetMapNative.Dispose(mapHandle);
return result;
}
}
public IReadOnlySet<string> Traits
{
get
{
IntPtr setHandle = Native_Get_traits(_handle, out IntPtr error);
if (error != IntPtr.Zero) throw NugetErrorNative.BuildException(error);
int count = NugetSetNative.Count(setHandle);
var result = new HashSet<string>(count);
for (int i = 0; i < count; i++)
{
result.Add(NugetMarshal.FromHandle<string>(NugetSetNative.ElementAt(setHandle, i)));
}
NugetSetNative.Dispose(setHandle);
return result;
}
}
MutableMap<K,V> (Schedule) and MutableSet<T> (Vaccinations) follow the same walk, exposed as IDictionary<K,V>/ISet<T> instead of the read-only interfaces.
Using it from C#
List<T>, from IntegrationTests/ListTests.cs:
[Fact]
public void Cat_Nicknames_ListEquality()
{
using var cat = new Cat("Oreo", 9);
IReadOnlyList<string> nicknames = cat.Nicknames;
Assert.Equal(new List<string> { "Oreoy", "Little Oreo" }, nicknames);
}
MutableList<T>, from IntegrationTests/MutableListTests.cs:
[Fact]
public void Cat_FavoriteFoods_IsMutable()
{
using var cat = new Cat("Oreo", 9);
IList<string> foods = cat.FavoriteFoods;
foods.Add("Chicken");
Assert.Equal(3, foods.Count);
Assert.Equal("Chicken", foods[2]);
}
Mutating the returned IList<string> only changes the C#-side copy. It does not write back to the Kotlin Cat instance, since the collection was eagerly copied at the moment of the property access.
Map<K,V>, from IntegrationTests/MapTests.cs:
[Fact]
public void Cat_Accessories_GetByKey()
{
using var cat = new Cat("Oreo", 9);
IReadOnlyDictionary<string, Toy> accessories = cat.Accessories;
using var collar = accessories["collar"];
Assert.Equal("Bell Collar", collar.Name);
Assert.Equal("Gold", collar.Color);
}
Set<T>, from IntegrationTests/SetTests.cs:
[Fact]
public void Cat_Traits_SetEquality()
{
using var cat = new Cat("Oreo", 9);
IReadOnlySet<string> traits = cat.Traits;
var expected = new HashSet<string> { "Fluffy", "Playful", "Curious" };
Assert.True(traits.SetEquals(expected));
}
Returned from a method or extension function
List<T>, Map<K,V>, Set<T>, and their mutable variants also marshal as class-method or extension-function returns, not only as properties. The collection is boxed as the same object carrier an object return uses, and the shared NugetListNative/NugetMapNative/NugetSetNative helpers materialize it exactly as they do for a property (see Method returns, ADR-061, and ADR-062):
// test-library/.../cat/Cat.kt
fun tags(): List<String> = listOf("$name-tag", "$name-chip")
fun scores(): List<Int> = listOf(lives, lives * 2)
// test-library/.../clinic/ClinicSample.kt
fun scores(): Map<String, Int> = mapOf("weight" to (weight ?: 0))
fun labels(): Set<String> = setOf(name)
public IReadOnlyList<string> Tags()
{
IntPtr listHandle = Native_Tags(_handle, out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
int count = NugetListNative.Count(listHandle);
var result = new List<string>(count);
for (int i = 0; i < count; i++)
{
result.Add(NugetMarshal.FromHandle<string>(NugetListNative.Get(listHandle, i)));
}
NugetListNative.Dispose(listHandle);
return result.AsReadOnly();
}
public IReadOnlyDictionary<string, int> Scores()
{
IntPtr mapHandle = Native_Scores(_handle, out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
int count = NugetMapNative.Count(mapHandle);
var result = new Dictionary<string, int>(count);
for (int i = 0; i < count; i++)
{
var key = NugetMarshal.FromHandle<string>(NugetMapNative.KeyAt(mapHandle, i));
var value = NugetMarshal.FromHandle<int>(NugetMapNative.ValueAt(mapHandle, i));
result[key] = value;
}
NugetMapNative.Dispose(mapHandle);
return result;
}
public IReadOnlySet<string> Labels()
{
IntPtr setHandle = Native_Labels(_handle, out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
int count = NugetSetNative.Count(setHandle);
var result = new HashSet<string>(count);
for (int i = 0; i < count; i++)
{
result.Add(NugetMarshal.FromHandle<string>(NugetSetNative.ElementAt(setHandle, i)));
}
NugetSetNative.Dispose(setHandle);
return result;
}
From IntegrationTests/ReturnAndPropertyMarshallingTests.cs:
[Fact]
public void Patient_Scores_ReturnsWeightMap()
{
using var patient = new Patient("Oreo");
patient.AdjustWeight(7);
var scores = patient.Scores();
Assert.Single(scores);
Assert.Equal(7, scores["weight"]);
}
[Fact]
public void Patient_Labels_ReturnsNameSet()
{
using var patient = new Patient("Oreo");
var labels = patient.Labels();
Assert.Single(labels);
Assert.Contains("Oreo", labels);
}
List<T> inputs (method parameters) also plan: the C# side builds a temporary handle via NugetMarshal.CreateList and the Kotlin side walks it. Map/Set inputs are not planned yet (there is no CreateMap/CreateSet helper); those callables are skipped rather than fallthrough-emitted.
Limitations
Sequence<T> is not bridgeable. Cat.unsupported: Sequence<String> in the sample library is deliberately left out of the generated Interop.cs (no eager-copy story for a lazy sequence).
Map/Set (and mutable variants) as method parameters are not planned: returns and properties work, but there is no create-handle helper for the C#→Kotlin direction yet. The callable is omitted from the generated C# and a SKIPPED_UNSUPPORTED_INPUT diagnostic names it, instead of the silent drop this used to be. Tracked in ROADMAP.md Phase 4 (ADR-062, ADR-064).
Last modified: 21 July 2026