kotlin-native-nuget Help

Collections

A Kotlin collection is eagerly copied into a real .NET collection when it crosses the bridge. There is no lazy bridging and no lasting connection to the Kotlin side afterward: mutating the C# collection you get back never writes back to Kotlin.

Kotlin

C#

List<T>

IReadOnlyList<T>

MutableList<T>

IList<T>

Map<K,V>

IReadOnlyDictionary<K,V>

MutableMap<K,V>

IDictionary<K,V>

Set<T>

IReadOnlySet<T>

MutableSet<T>

ISet<T>

val nicknames: List<String> = listOf("${name}y", "Little $name") val favoriteFoods: MutableList<String> = mutableListOf("Tuna", "Salmon")
using var cat = new Cat("Oreo", 9); IReadOnlyList<string> nicknames = cat.Nicknames; IList<string> foods = cat.FavoriteFoods; foods.Add("Chicken"); // only changes this C# copy; Cat's own list is untouched

A var-declared collection property can still be reassigned: cat.FavoriteFoods = new List<string> { "Chicken" } replaces the whole collection on the Kotlin side. See Mutable collection properties for what element types support this.

Method parameters and returns

List, Map, Set, and their mutable variants marshal the same way as class-method, top-level function, extension-function, and constructor parameters and return values, not only as properties.

class Patient(val name: String) { fun recordScores(scores: Map<String, Int>): Int = scores.values.sum() fun tallyScores(scores: MutableMap<String, Int>): Int { scores["total"] = scores.values.sum() return scores.size } }
using var oreo = new Patient("Oreo"); int total = oreo.RecordScores(new Dictionary<string, int> { ["agility"] = 3, ["cuddles"] = 4 }); // 7 var scores = new Dictionary<string, int> { ["agility"] = 3 }; int tally = oreo.TallyScores(scores); // 2, scores.ContainsKey("total") is false

A MutableMap/MutableSet parameter does not write back: Kotlin gets its own copy, and changes it makes inside the function are not reflected in the Dictionary/HashSet you passed. This matches MutableList's existing input behavior.

A plain Dictionary/HashSet is directly assignable at every parameter kind, but a bare IDictionary<K,V> cannot be passed to a Map<K,V> parameter (it needs IReadOnlyDictionary), and a bare IReadOnlyDictionary<K,V> cannot be passed to a MutableMap<K,V> parameter (it needs IDictionary). The same asymmetry applies to Set/ISet/IReadOnlySet.

Nullable collection references

A List/Map/Set property or parameter can itself be nullable (List<String>?), independent of whether its elements are. A null Kotlin value comes back as C# null, never an empty collection.

data class Visit( val patient: String, val symptoms: List<String>, val notes: List<String>? = null, )
using var visit = new Visit("Mylo", new List<string> { "sneezing" }, null); Assert.Null(visit.Notes);

A class method or extension function can return a nullable collection the same way (fun ids(): List<ChartId>?); the C# return type carries the ? and null means the same thing it does above, never an empty collection:

public IReadOnlyList<global::TestLibrary.Clinic.ChartId>? Ids()

This extends to a suspend fun returning a nullable collection, but not to a Flow/StateFlow element (Flow<List<T>?>), which stays unsupported. See Coroutines and Flow.

Mutable collection properties

A var-declared collection property always gets a getter. It gets a setter only when every component (the element for List/Set, the key and value for Map) is one of the supported collection component types below. If a component isn't supported (today: a nullable nested collection), the property still generates, but read-only, and the compiler emits a SKIPPED_UNSUPPORTED_INPUT diagnostic naming the property.

class Chart(val patientName: String) { var tags: List<String> = emptyList() var seen: MutableList<Nurse> = mutableListOf() }
using var chart = new Chart("Oreo"); chart.Tags = new List<string> { "black", "white", "biscuit" }; Assert.Equal(new[] { "black", "white", "biscuit" }, chart.Tags);

The getter always returns a detached copy, the same as any other collection return: mutating chart.Seen in place does not reach Kotlin. Read, extend on the C# side, and reassign to get the change across:

var attending = new List<Nurse>(chart.Seen) { barton }; chart.Seen = attending;

The same eligibility rule applies to a collection-typed extension property, including one whose receiver crosses by value (a String - or object-underlying value class) rather than by object handle.

Collection component types

A List/Map/Set element, map key, or map value supports: String; the wide primitives (Int, Long, Float, Double, Boolean); the narrow primitives and Char (Byte/UByte/Short/UShort/UInt/ULong, see below); an object handle (a class instance); a bare enum via its int ordinal (see Enums); a value class over any of the above underlyings (see Value classes); a sealed base, which boxes as an object handle the same way a concrete class does (see Interfaces, abstract classes, and sealed classes); Instant, Duration and Uuid, as DateTimeOffset, TimeSpan and Guid (see Primitives and strings); a ByteArray, as byte[] (see below); an interface (see Interfaces as collection components); and a List/Map/Set itself, nested at arbitrary depth; and a function or generic class's own type parameter T (see A type parameter as an element). This applies uniformly to parameters, method and property returns, and collection property setters.

A nullable spelling of any of those (Map<String, Int?>, Set<String?>, List<Mood?>, List<Short?>, List<List<String?>>) is also supported: a null element, set member, or map value rides a null pointer in that component's slot, both reading and writing.

One shape is not supported and fails with a named diagnostic rather than binding incorrectly: a nullable nested collection (List<List<String>?>). A nullable map key (Map<String?, Int>) is also unsupported, at every position a map appears (parameter, return, property, and nested), since a C# Dictionary can't hold a null key and the generated read path would otherwise fail to compile. A nullable map value (Map<String, Int?>) is unaffected. A ByteArray is unsupported as a Set element or a map key, for the reason given below.

ByteArray as a collection component

A ByteArray binds as a List/MutableList element and as a Map/MutableMap value, spelled byte[], at every position a component reaches: parameter, return, property getter and setter, constructor parameter, and nested (List<List<ByteArray>>).

fun burstChunks(data: ByteArray, size: Int): List<ByteArray> = data.toList().chunked(size).map { it.toByteArray() } fun rewindCollars(bursts: Map<String, ByteArray>): Map<String, ByteArray> = bursts.mapValues { (_, burst) -> burst.reversedArray() }
IReadOnlyList<byte[]> chunks = PayloadKt.BurstChunks(new byte[] { 1, 2, 3 }, 2); // [[1, 2], [3]] IReadOnlyDictionary<string, byte[]> rewound = PayloadKt.RewindCollars( new Dictionary<string, byte[]> { ["oreo"] = new byte[] { 1, 2, 3 } }); // rewound["oreo"] is [3, 2, 1]

Each element is a fresh copy per crossing, exactly as a standalone byte[] is. A null element (List<ByteArray?>) is supported in both directions and rides the null pointer in that slot:

fun patchySignals(): List<ByteArray?> = listOf( byteArrayOf(1, 2), null, byteArrayOf(), byteArrayOf(0x00, 0xFF.toByte()), )
IReadOnlyList<byte[]?> signals = PayloadKt.PatchySignals(); // [ [1, 2], null, [], [0, 255] ]

The same component binds on the legacy suspend/Flow routes:

suspend fun bursts(): List<ByteArray> { /* ... */ } suspend fun snapshot(): ByteArray { /* ... */ } val pulses: Flow<ByteArray> = flowOf(byteArrayOf(1), byteArrayOf(0x7F, 0xFF.toByte()))
public Task<IReadOnlyList<byte[]>> BurstsAsync(CancellationToken cancellationToken = default) public Task<byte[]> SnapshotAsync(CancellationToken cancellationToken = default) public KotlinFlow<byte[]> Pulses { get; }

A bare ByteArray (no surrounding collection) binds the same way a component does, straight to Task<byte[]> and KotlinFlow<byte[]>, and a ByteArray parameter on those members binds as byte[] too; see Coroutines and Flow.

A Set<ByteArray> element and a Map<ByteArray, V> key are deliberately not supported (a value class over a ByteArray is, and compares by content): arrays compare by identity in Kotlin and in C# alike, and every crossing copies, so the byte[] you hold is never the array the Kotlin container hashed and no membership test or lookup could ever succeed. Use a List<ByteArray> when you want order, or key the map by a String (or a value class over one) such as a hex or Base64 digest.

Narrow primitives and Char as collection components

The six narrow primitives and Char bind as a List/Map/Set element, key, or value the same way the wide primitives do:

class Readings { fun chart(samples: List<Short>): String = samples.joinToString(",") fun census(byGrade: Map<UByte, Short>): String = byGrade.entries.sortedBy { it.key }.joinToString(",") { "${it.key}=${it.value}" } fun marks(): List<Char> = listOf('é', '日') }
using var readings = new Readings(); string chart = readings.Chart(new short[] { 1, -2, short.MaxValue }); // "1,-2,32767" IReadOnlyList<char> marks = readings.Marks(); // ['é', '日']

See Primitives and strings: Char for Char's behavior outside collections, including why a standalone Char? is unsupported.

Nested collections

A List<List<String>>-shaped parameter, return, or property works at any depth and for any combination of outer/inner kind (Set<List<T>>, Map<K, List<V>>, and so on). The inner collection crosses as its own handle, in the same component slot every other element type uses.

class WardBoard { fun logGrid(rows: List<List<String>>): String = rows.joinToString(";") { it.joinToString(",") } fun grid(): List<List<String>> = listOf(listOf("oreo", "mylo"), listOf("biscuit")) }
using var board = new WardBoard(); IReadOnlyList<IReadOnlyList<string>> grid = board.Grid(); Assert.Equal(new[] { "oreo", "mylo" }, grid[0]); string logged = board.LogGrid(new[] { new[] { "oreo", "mylo" }, new[] { "biscuit" } }); // "oreo,mylo;biscuit"

Interfaces as collection components

A Kotlin interface binds as its I-prefixed C# interface inside a List, Set or Map (as a value or a key), at every position: property (get and set), method or top-level return, parameter, suspend return and Flow element, nullable elements included.

class FosterHome { fun residentsNow(): List<Pet> = listOf(oreo, mylo) fun roll(pets: List<Pet>): String = pets.joinToString(" | ") { "${it.name}: ${it.speak()}" } fun echo(pets: List<Pet>): List<Pet> = pets fun byName(): Map<String, Pet> = mapOf("oreo" to oreo, "mylo" to mylo) suspend fun residentsLater(): List<Pet> = listOf(oreo, mylo) }
public IReadOnlyList<global::TestLibrary.Cat.IPet> ResidentsNow() public string Roll(IReadOnlyList<global::TestLibrary.Cat.IPet> pets) public IReadOnlyList<global::TestLibrary.Cat.IPet> Echo(IReadOnlyList<global::TestLibrary.Cat.IPet> pets) public IReadOnlyDictionary<string, global::TestLibrary.Cat.IPet> ByName() public Task<IReadOnlyList<global::TestLibrary.Cat.IPet>> ResidentsLaterAsync(CancellationToken cancellationToken = default)

You can pass Kotlin-backed and C#-implemented elements in the same list. Kotlin calls each one, so a C# Dog answers from C#, and a C# element that comes back is your own object:

using var home = new FosterHome(); using var oreo = new Cat("Oreo"); var rex = new Dog("Rex"); string rollCall = home.Roll(new IPet[] { oreo, rex }); // "Oreo: ... | Rex: Woof!" IReadOnlyList<IPet> back = home.Echo(new IPet[] { rex, oreo }); Assert.Same(rex, back[0]); // your own object back[1].Dispose(); // a Kotlin-backed element

A Kotlin-backed element is a fresh wrapper of the interface (Pet, never Cat, the concrete class), so do not cast it down. You own every wrapper a returned collection hands you: dispose each element you keep, as you would for a single interface return (Interface-typed return values). A map keyed by an interface compares keys by reference, so look a key up with the instance the collection gave you or that you passed in, not with another wrapper over the same Kotlin object.

Exception safety on collection parameters and returns

A temporary handle built for a collection parameter is released on every exit path, including when the Kotlin callee throws. If enumerating your own IEnumerable argument throws partway through (for example, a custom collection that fails mid-MoveNext), the partially built native handle is disposed and your original exception surfaces unmasked, not wrapped or replaced by a Kotlin-side error.

The read side is symmetric: if materializing a returned collection's elements throws partway through (an element factory failure), the collection's own handle is still disposed and every element wrapper already built before the throw is disposed too. You don't need to add cleanup code for either direction; both are handled by the generated bridge code. Only wrappers the bridge built are disposed: a C#-implemented interface element you passed in is never disposed on your behalf.

Limitations

  • Sequence<T> does not cross the bridge; there is no eager-copy story for a lazy sequence. It is left out of the generated C# entirely.

  • MutableMap/MutableSet parameters (and MutableList) don't write back; see Method parameters and returns.

  • A collection property setter is narrower than its getter; see Mutable collection properties.

Last modified: 03 November 2026