kotlin-native-nuget Help

Coroutines and Flow

Kotlin coroutines map onto .NET's own async model. suspend fun becomes async/Task<T>, cancellation maps to CancellationToken, and disposing the owning object cancels any coroutine it started. Flow<T> becomes IAsyncEnumerable<T>, and StateFlow<T> adds a synchronously readable .Value.

Kotlin

C#

suspend fun

async Task<T>, suffixed Async

suspend (A) -> R lambda property

Func<A, CancellationToken, Task<R>>, see below

suspend (A) -> R as a parameter

Func<A, CancellationToken, Task<R>>, see below

Flow<T> as a parameter

IAsyncEnumerable<T>, see below

Flow<T>

KotlinFlow<T> : IAsyncEnumerable<T>

Flow family member of an object, companion, top level, enum or value class

the same wrapper, static or extension, see below

SharedFlow<T>

KotlinSharedFlow<T> : KotlinFlow<T>, replays the Kotlin replay cache first, never completes, adds ReplayCache

MutableSharedFlow<T> (declared, not narrowed to SharedFlow<T>)

KotlinMutableSharedFlow<T> : KotlinSharedFlow<T>, adds SubscriptionCount, EmitAsync and TryEmit

Flow<T?>

KotlinFlow<T?>, a null emission is a genuine item

StateFlow<T>

KotlinStateFlow<T> : KotlinFlow<T>, adds a synchronous .Value

MutableStateFlow<T> (declared, not narrowed to StateFlow<T>)

KotlinMutableStateFlow<T> : KotlinStateFlow<T>, .Value is settable

Flow<T> with a type parameter of a class or method

KotlinFlow<T>, see below

Flow<T> or suspend (T) -> R parameter over a type parameter

IAsyncEnumerable<T>/Func<T, CancellationToken, Task<T>>, see below

coroutine cancellation

CancellationToken

structured concurrency

Dispose() cancels in-flight work; DisposeAsync() drains it

suspend fun

From AsyncCatService.kt:

class AsyncCatService(private val prefix: String) { suspend fun fetch(): String { delay(5.seconds) return "$prefix result" } suspend fun fetchCat(name: String): Cat { delay(5.seconds) return Cat(name) } }
using var service = new AsyncCatService("toys"); using var cat = await service.FetchCatAsync("Oreo");

Every suspend fun becomes an async Task<T> method suffixed Async. Overloads resolve as an ordinary C# overload set, whether declared on a class or at the top level:

suspend fun fetchTreat(): String = "one treat for Oreo" suspend fun fetchTreat(count: Int): String = "$count treats for Mylo"
await AsyncFunctions.FetchTreatAsync(); // "one treat for Oreo" await AsyncFunctions.FetchTreatAsync(3); // "3 treats for Mylo"

A suspend fun binds at the top level, on a class or sealed type, on an object, a companion, an enum, a value class, as an extension, and on an interface (see below for the static owners).

suspend fun returning a nullable type

A nullable primitive, String?, or object return carries its ? all the way through:

suspend fun findShelterCat(catName: String): Cat? { delay(100.milliseconds) return if (catName == "Mylo") Cat(catName) else null } suspend fun countTreatsLeft(catName: String): Int? { delay(100.milliseconds) return if (catName == "Oreo") 7 else null }
public static Task<Cat?> FindShelterCatAsync(string catName, CancellationToken cancellationToken = default) public static Task<int?> CountTreatsLeftAsync(string catName, CancellationToken cancellationToken = default)

null stays distinct from 0 or an empty string, the same as a nullable synchronous return.

suspend fun returning a collection

class Headcount(private val names: List<String>) { suspend fun ids(): Set<Int> { /* ... */ } suspend fun ages(): Map<String, Int> { /* ... */ } }
public Task<IReadOnlySet<int>> IdsAsync(CancellationToken cancellationToken = default) public Task<IReadOnlyDictionary<string, int>> AgesAsync(CancellationToken cancellationToken = default)

List<T>, Set<T>, and Map<K, V> returns are spelled the same way a property of that type is. A closed instantiation of your own generic class (Box<String>) is a handle and binds (Generics). Any other generic return (Pair<A, B>, Flow<T>) has no C# binding and is skipped with a diagnostic naming the member; expose the values through separate suspend functions instead. A Result<T> return is the exception, see below.

A nullable collection completes with null, never an empty collection, and an element that is a sealed base binds as the base type, each element arriving as its concrete arm. Both work on a class, a sealed base or arm, and at top level:

class Headcount(private val names: List<String>) { suspend fun maybeTags(present: Boolean): List<String>? { /* ... */ } } class Issue54Studio { suspend fun sketch(): List<Issue54Shape> { /* ... */ } }
public Task<IReadOnlyList<string>?> MaybeTagsAsync(bool present, CancellationToken cancellationToken = default) public Task<IReadOnlyList<global::TestLibrary.Issue54.Issue54Shape>> SketchAsync(CancellationToken cancellationToken = default)

Flow<List<Shape>> binds as KotlinFlow<IReadOnlyList<Shape>> the same way.

suspend fun returning a type from a dependency module

A class or value class reached only through a top-level suspend fun, never through any ordinary member, binds the same way it would anywhere else, once its package is in scope (see The nuget {} DSL: cross-module export closure):

// dev.other.bysuspend, a dependency module admitted with admit("dev.other.bysuspend") class Mousetoy(val squeak: String) { fun batted(by: String): String = "$by bats the $squeak mouse under the sofa" } value class Chipcode(val digits: String) // io.github.xxfast.kotlin.native.nuget.test.errand suspend fun fetchMousetoy(catName: String): Mousetoy { /* ... */ } suspend fun squeakOf(toy: Mousetoy): String { /* ... */ } suspend fun scanChip(catName: String): Chipcode { /* ... */ }
public static Task<global::TestLibrary.Dev.Other.Bysuspend.Mousetoy> FetchMousetoyAsync( string catName, CancellationToken cancellationToken = default); public static Task<string> SqueakOfAsync( global::TestLibrary.Dev.Other.Bysuspend.Mousetoy toy, CancellationToken cancellationToken = default); public static Task<global::TestLibrary.Dev.Other.Bysuspend.Chipcode> ScanChipAsync( string catName, CancellationToken cancellationToken = default);

Chipcode completes through the same unboxing a value class uses at any other position; an enum return completes the same way an enum parameter binds, by ordinal. A dependency type outside the admitted scope at this same position is a named skip pointing at admit(...); it is never spelled as an undeclared C# type, whether it appears at a top-level or a class-member suspend signature.

suspend fun returning an interface

suspend fun strayPetLater(): Pet = strayPet()
public static Task<global::TestLibrary.Cat.IPet> StrayPetLaterAsync(CancellationToken cancellationToken = default)

The result is typed with the interface itself, the same as a synchronous interface return, and resolves back to the caller's own instance if a C# type implemented that interface. See Interfaces, abstract classes and sealed classes.

A nullable interface return (suspend fun handBackLaterOrNull(): Pet?) carries its ? the same way a nullable primitive or object return does:

public Task<global::TestLibrary.Cat.IPet?> HandBackLaterOrNullAsync(CancellationToken cancellationToken = default)

null crosses as null, checked before the identity probe runs, and a non-null, C#-implemented result still resolves back to the caller's own instance.

suspend fun returning StateFlow<T>

A suspend fun can suspend before handing back a StateFlow<T>, for example to build it lazily. The outer suspend stays a Task; once awaited, .Value and await foreach behave exactly like an ordinary StateFlow<T> (below).

suspend fun awaitMoodReport(): StateFlow<String> { delay(1) return mood }
public Task<KotlinStateFlow<string>> AwaitMoodReportAsync(CancellationToken cancellationToken = default)

A top-level suspend fun gets the same holder, as a static method on its file's class:

suspend fun watchPurrCount(): StateFlow<Int> { delay(1) return purrCount.asStateFlow() }
public static Task<KotlinStateFlow<int>> WatchPurrCountAsync(CancellationToken cancellationToken = default)

A top-level function has no owning object to cancel its collections, so each await foreach runs until its own enumerator is disposed or its token is cancelled. Dispose the holder when you are done with it, as with a class method's.

A class suspend fun whose declared return is MutableStateFlow<T> awaits to a settable KotlinMutableStateFlow<T>, written the same way as the property form:

using KotlinMutableStateFlow<int> jar = await tracker.AwaitTreatJarAsync(); jar.Value = 4;

A top-level suspend fun returning MutableStateFlow<T> awaits to a settable holder as well, and so does one on an object, a companion, an enum or a value class.

StateFlow<T> element type is an interface

The element can itself be an interface. It is spelled and read through the interface, the same as any other interface element:

suspend fun keeperReport(): StateFlow<Keeper> { yield() return onDuty }
public Task<KotlinStateFlow<global::TestLibrary.Nested.Aviary.IKeeper>> KeeperReportAsync(CancellationToken cancellationToken = default)

A C#-implemented keeper booked earlier and read back through .Value is the same instance the caller passed in.

Nullable element or nullable holder

A nullable element carries its ? onto the holder, and a null is a genuine value for .Value and await foreach. A nullable return awaits to null when Kotlin hands back no flow, so check it before use:

suspend fun watchNapStreak(): StateFlow<Int?> // Task<KotlinStateFlow<int?>> suspend fun watchDen(): StateFlow<Cat>? // Task<KotlinStateFlow<Cat>?>
using KotlinStateFlow<int?> streak = await CatWatch.WatchNapStreakAsync(); int? days = streak.Value; using KotlinStateFlow<Cat>? den = await CatWatch.WatchDenAsync(); if (den is not null) { using Cat cat = den.Value; }

Both work on class methods and top-level functions, and combine as StateFlow<T?>?. A suspend fun returning MutableStateFlow<T?> awaits to a settable holder whatever the element, and null is a valid write. One returning MutableStateFlow<T>? (the holder itself nullable) binds a read-only holder; its .Value setter is not available.

Collection element

A List, Set or Map element awaits to the same read-only collection a StateFlow property gives, so .Value and await foreach return the collection, value-class elements included:

suspend fun awaitLitterSizes(): StateFlow<List<Int>>
using KotlinStateFlow<IReadOnlyList<int>> litters = await tracker.AwaitLitterSizesAsync(); IReadOnlyList<int> sizes = litters.Value;

A suspend fun (): MutableStateFlow<List<T>> awaits to a settable KotlinMutableStateFlow<IReadOnlyList<T>>, and so does one over a ByteArray or an interface: each write mints the handle for the call and releases it afterwards. A nullable element (MutableStateFlow<List<T>?>) awaits settable too and null clears it; see Nullable collection element. An awaited nullable StateFlow<List<T>>? is not covered by an end-to-end test.

suspend fun returning Flow<T>

A suspend function that returns Flow<T> stays asynchronous: await it once to acquire a KotlinFlow<T>. Acquisition does not start collection; later enumerations use the same Flow object with its usual Kotlin Flow semantics.

class SuspendFlowCafe { suspend fun portions(): Flow<Int> = flow { emit(17) emit(29) emit(43) } }
await using var cafe = new SuspendFlowCafe(); using KotlinFlow<int> portions = await cafe.PortionsAsync(); await foreach (int portion in portions) Console.WriteLine(portion);

Dispose the acquired KotlinFlow<T> when finished. Dispose() releases its holder and prevents new collections; a collection already in progress continues. On the owner, Dispose() cancels active work and DisposeAsync() drains it. The acquisition CancellationToken controls only acquisition; use WithCancellation or the enumerator token to cancel a collection. A top-level function has no owner, so dispose the holder and cancel collection through its enumerator or token.

A suspend fun with its own type parameter

A suspend fun <T> becomes a generic Task<T> method, with the same where clauses and boxed T as a generic function. It binds at the top level, on a class, and on a sealed arm, including on an owner with a type parameter of its own (next section).

suspend fun <T> fetchLater(value: T): T { /* ... */ } suspend fun <T : Any> describeLater(value: T): String { /* ... */ } suspend fun <T> shuffleLater(list: List<T>): List<T> { /* ... */ } class Courier { suspend fun <T> ferry(value: T): T { /* ... */ } }
public static Task<T> FetchLaterAsync<T>(T value, CancellationToken cancellationToken = default) public static Task<string> DescribeLaterAsync<T>(T value, CancellationToken cancellationToken = default) where T : notnull public static Task<IReadOnlyList<T>> ShuffleLaterAsync<T>(IReadOnlyList<T> list, CancellationToken cancellationToken = default) public Task<T> FerryAsync<T>(T value, CancellationToken cancellationToken = default)
Assert.Equal(4, await SuspendGenericSample.FetchLaterAsync(4)); using Toy back = await SuspendGenericSample.FetchLaterAsync(toy); // the same Kotlin object, yours to dispose
  • A builtin, a string, a wrapper or a C# implementation of an exported interface all work as T. A List, Set or Map of the function's own T binds too.

  • A type argument outside a bound C# cannot name (T : Number) is accepted by the compiler and faults the returned Task with KotlinInvalidCastException. It does not throw at the call.

  • A C# implementation passed as T stays callable by Kotlin after the call returns, for the whole suspension. A wrapper you pass is borrowed and must stay alive until the Task completes.

  • Cancelling the token cancels the Task, as on any other suspend fun.

suspend on an object, companion, extension, enum or value class

A suspend member of an object, a companion, an enum, a value class or a suspend extension binds as a Task-returning method with a trailing CancellationToken, the same as a top-level function. These owners have nothing to dispose, so each call runs on its own scope and the token is the only way to cancel it. A member's own T binds as on a top-level function.

object Siesta { suspend fun minutesFor(name: String): Int } class Swing(val sleeper: String) { companion object { suspend fun hang(sleeper: String): Swing } } suspend fun Swing.rock(times: Int): String suspend fun String.yawn(): String enum class NapSpot { WINDOWSILL, BOX; suspend fun claim(cat: String): String }
int minutes = await Siesta.MinutesForAsync("Oreo"); await using Swing swing = await Swing.HangAsync("Oreo"); string rocked = await swing.RockAsync(2); string yawn = await "Mylo".YawnAsync(); string claim = await NapSpot.Box.ClaimAsync("Mylo");

Kotlin owner

C# placement

object

static method on the object's static class

companion (default or named)

static method on the owning class

extension on any receiver a parameter binds as

this extension method in {Receiver}Extensions

enum body member

this extension method in {Enum}Extensions

enum companion member

static method in {Enum}Extensions

value class

instance method on the struct

A suspend extension binds on every receiver a parameter of that type binds as: a generic class or bare T (await bindle.UnpackLaterAsync()), a collection, an interface, a bound C# interface, a value class, an enum, Uuid, Instant, Duration, ByteArray, and the nullable form of each. See Extensions for how each looks in C#.

suspend fun List<Int>.total(): Int suspend fun <T> Bindle<T>.unpackLater(): T suspend fun Loafer.watch(count: Int): Flow<Int>
int total = await new List<int> { 1, 2 }.TotalAsync(); int top = await bindle.UnpackLaterAsync(); using KotlinFlow<int> ticks = await mylo.WatchAsync(3); // a Flow result awaits to the wrapper

A companion member and an instance member can share a C# name when their parameters differ (Swing.SwayAsync(3) and swing.SwayAsync()). With identical parameters the build fails with ERROR_CSHARP_SIGNATURE_COLLISION: rename one in Kotlin.

A trampolined bound (T : Enum<T>, which C# cannot spell for Kotlin) binds on the parameter too:

suspend fun <T : Enum<T>> rateLater(medal: T): T { /* ... */ }
public static Task<T> RateLaterAsync<T>(T medal, CancellationToken cancellationToken = default) where T : struct, Enum

An out-of-bound value faults the Task instead of throwing at the call.

suspend fun returning Result<T>

A suspend fun returning Result<T> binds as Task<T> on every suspend owner: a class, the top level, an object, a companion, an enum, a value class and an extension. A success completes the task with the payload. A failure faults the task with the exception C# would see for a thrown one, so catch it around the await. There is no TryXAsync twin, because the Task already carries the failure.

suspend fun fetchKibble(ok: Boolean): Result<String> = if (ok) Result.success("kibble") else Result.failure(IllegalArgumentException("the bowl is empty")) suspend fun Dozer.wakeOrFail(ok: Boolean): Result<String> = /* ... */
string kibble = await Bindles.FetchKibbleAsync(true); // "kibble" await Bindles.FetchKibbleAsync(false); // throws ArgumentException: "the bowl is empty"

A suspend inline fun <reified T> ...: Result<T> binds the same way, with T reified at its bound. Result<T> at a suspend parameter or inside another type is still unsupported.

Flow on an object, companion, top level, enum or value class

A Flow, StateFlow, SharedFlow, MutableStateFlow or MutableSharedFlow return or property binds as the matching KotlinFlow<T> wrapper on every owner that has no instance to dispose: the top level, an object, a companion (default or named), an enum body and enum companion, and a value class, generic or not. A member's own T binds as on a class member, and so does a nullable collection element (MutableStateFlow<List<Int>?>, see Nullable collection element).

fun purrs(count: Int): Flow<Int> val bedtime: MutableStateFlow<Int> object PurrClock { val beats: Flow<Int> fun <T> echo(value: T, times: Int): Flow<T> } class Cuckoo { companion object { fun calls(): Flow<String> } } class Hourglass { companion object Sand { val level: StateFlow<Int> } } enum class Chime { WESTMINSTER, CUCKOO; fun play(): Flow<Int>; val echo: Flow<String> } value class Pendulum(val millis: Int) { fun beats(count: Int): Flow<Int> }
await foreach (int n in Chimes.Purrs(3)) { } // top level: the file's static class await foreach (int n in PurrClock.Beats) { } await foreach (string s in PurrClock.Echo("Mylo", 2)) { } await foreach (string s in Cuckoo.Calls()) { } // companion: static on the owner using KotlinStateFlow<int> level = Hourglass.Level; // named companion: still static on Hourglass await foreach (int n in Chime.Westminster.Play()) { } // enum body: an extension member await foreach (string s in Chime.Cuckoo.Echo) { } // extension property (C# 14) await foreach (int n in new Pendulum(250).Beats(3)) { } // value class: a struct member using KotlinMutableStateFlow<int> bedtime = Chimes.Bedtime; bedtime.Value = 22;

Differences from a class member:

  • There is no owner to dispose, so each await foreach runs on its own scope and only its enumerator ends it: finish the loop, break, or pass a token to WithCancellation.

  • A cold Flow<T> re-runs the Kotlin function or re-reads the property on every enumeration. Enumerating one KotlinFlow<int> twice starts from the top both times.

  • A cold flow holds nothing until you enumerate it, so it needs no Dispose(). A returned or read MutableStateFlow or MutableSharedFlow holds the Kotlin flow itself, so using it. Writes through it, and through a second holder of the same Kotlin flow, are shared Kotlin storage: setting PurrClock.Hour is visible through PurrClock.Alarm().

  • A property read mints a new holder each time, so Chimes.Bedtime read twice gives two holders over one flow.

  • A var flow property is settable, as on a class. It takes a Kotlin flow, a KotlinFlow<T>.From source for a cold one, or a Created hot holder. Assigning can run the owner's or file's initialiser, so unlike an instance setter it can throw the Kotlin exception. An enum body's property is an extension property, which C# cannot make settable, so its setter is a Set<P> method:

    Pantry.Dial = KotlinMutableStateFlow<int>.Create(9); // object (or top level, companion) Meal.Dinner.SetMenu(KotlinFlow<string>.From(Each("chicken"))); // Meal.Breakfast.Menu is untouched
  • An enum's flow property (a body val, a constructor val, or an abstract val each entry overrides) is an extension property on {Enum}Extensions of every flow kind. Each entry keeps its own storage, so Tail.Oreo.Height and Tail.Mylo.Height are two different MutableStateFlows, and an overridden abstract val runs the entry's own body:

    using KotlinMutableStateFlow<int> oreo = Tail.Oreo.Height; oreo.Value = 42; // Tail.Mylo.Height is untouched await foreach (string s in Tail.Mylo.Mood) { }

    Reach these through a plain using of the namespace or using static of the extensions class (using static TestLibrary.Purrclock.TailExtensions;); a C# 14 extension property is not found through a namespace alias.

  • A suspend member with a flow result awaits to the same wrapper (await PurrClock.WinderAsync() is a settable KotlinMutableStateFlow<int>, the top level included).

  • A sync extension returning a Flow, StateFlow or SharedFlow (fun Loafer.ticks(count: Int): Flow<Int>) binds the same way as mylo.Ticks(3), on a class, generic, nullable, collection or nullable enum receiver. See Extensions.

suspend members of a generic class

A generic class, and a generic sealed arm such as Dish.Hot<T>, binds its suspend members the way a non-generic class does. The owner T crosses boxed as it does on a generic method, and the class implements IAsyncDisposable, so await using it.

class Platter<T>(served: T) { suspend fun take(): T { /* ... */ } suspend fun put(item: T) { /* ... */ } suspend fun <M : Enum<M>> rate(medal: M): M { /* ... */ } } class Picnic<T>(host: T) where T : Furry, T : Tricky { suspend fun invite(guest: T): String { /* ... */ } }
public class Platter<T> : IDisposable, IAsyncDisposable { public Task<T> TakeAsync(CancellationToken cancellationToken = default); public Task PutAsync(T item, CancellationToken cancellationToken = default); public Task<M> RateAsync<M>(M medal, CancellationToken cancellationToken = default) where M : struct, Enum; } public class Picnic<T> : IDisposable, IAsyncDisposable where T : IFurry, ITricky { public Task<string> InviteAsync(T guest, CancellationToken cancellationToken = default); }
await using var platter = new Platter<int>(4); await platter.PutAsync(3); Assert.Equal(3, await platter.TakeAsync()); Assert.Equal(Medal.Gold, await platter.RateAsync(Medal.Gold)); await Assert.ThrowsAnyAsync<ArgumentException>(() => platter.RateAsync(Medal.Bronze)); // Kotlin's require failed
  • A multi-bound T (Picnic) works too: the argument is checked against every bound inside the coroutine, so a value that fails faults the Task.

  • An inner class of a generic owner and a suspend member of a generic sealed base are still skipped, named. A generic sealed arm binds them itself, so declare the member on the arm.

suspend () -> R lambda properties

val onFeedWith: suspend (String) -> String = { food -> delay(1.seconds) "$catName devoured the $food!" }
using var feeder = new CatFeeder("Mylo"); Func<string, CancellationToken, Task<string>> onFeedWith = feeder.OnFeedWith; string result = await onFeedWith("salmon", CancellationToken.None);

The property is Func<A, CancellationToken, Task<R>> (Task for Unit), nullable when the Kotlin type is, on every owner. Cancelling the token throws TaskCanceledException from the awaited call. A var takes your own async delegate, and Kotlin awaits it; see Lambdas and callbacks.

A suspend lambda as a parameter

A Kotlin parameter of type suspend (T) -> R binds as Func<T, CancellationToken, Task<R>>, or Func<T, CancellationToken, Task> when the result is Unit. The delegate can take up to three payloads before the token, so suspend () -> R is Func<CancellationToken, Task<R>>.

class TreatChef(val cat: String) { suspend fun plate(count: Int, prepare: suspend (Int) -> String): String { val plated: MutableList<String> = mutableListOf() for (treat in 1..count) plated += prepare(treat) return plated.joinToString(",") } suspend fun serve(count: Int, bowl: suspend (Int) -> Unit): Int { /* ... */ } }
public Task<string> PlateAsync(int count, Func<int, global::System.Threading.CancellationToken, global::System.Threading.Tasks.Task<string>> prepare, CancellationToken cancellationToken = default) public Task<int> ServeAsync(int count, Func<int, global::System.Threading.CancellationToken, global::System.Threading.Tasks.Task> bowl, CancellationToken cancellationToken = default)
using var chef = new TreatChef("Oreo"); string plated = await chef.PlateAsync(3, async (treat, token) => { await Task.Delay(10, token); return $"tuna#{treat}"; }); // "tuna#1,tuna#2,tuna#3": Kotlin awaits each invocation before starting the next

Pass the token you are given to anything you await: when Kotlin cancels the invocation (a timeout, or the CancellationToken you passed to PlateAsync), that token is cancelled. A lambda that ignores it keeps running until it finishes, and its captured state stays alive until then.

The same parameter binds on a class suspend fun, a suspend fun on a sealed arm, a top-level suspend fun, and on a non-suspend method or constructor that stores the lambda for later. Kotlin may call a stored lambda any number of times after the constructor returns:

class TreatTimer(private val prepare: suspend (Int) -> String) { suspend fun ring(count: Int): String { /* calls prepare(1)..prepare(count) */ } }
public TreatTimer(Func<int, global::System.Threading.CancellationToken, global::System.Threading.Tasks.Task<string>> prepare)
using var timer = new TreatTimer(async (treat, token) => { await Task.Delay(1, token); return $"ding{treat}"; }); await timer.RingAsync(2); // "ding1,ding2" await timer.RingAsync(3); // "ding1,ding2,ding3"

Lifetime, threads and errors

  • Kotlin owns the delegate. It, and everything it captures, is released by a Kotlin cleaner after Kotlin has dropped the lambda, so release follows a Kotlin garbage-collection round and not the end of the call. This is the same rule as a C# class implementing a Kotlin interface. Do not rely on a captured IDisposable being disposed when the call returns; dispose it yourself.

  • Threads. The lambda starts on the Kotlin dispatcher thread that invoked it. After its first await that does not complete synchronously it continues on the .NET thread pool, never on the caller's SynchronizationContext, so do not touch UI state from inside it.

  • Errors. A throw, whether synchronous or a faulted Task, reaches the Kotlin line that invoked the lambda as an exception Kotlin can catch. If Kotlin does not catch it, the outer Task from PlateAsync faults with the original message; catch Exception rather than relying on the original type. A throw of OperationCanceledException, or a cancelled returned Task, cancels the invoking coroutine and surfaces as a cancellation, not a fault.

  • Types. A payload or result is anything in the callback vocabulary: primitives, String, Char, an enum, an exported class or bound C# interface, a collection, ByteArray, Instant, Duration, Uuid, a value class, a member's own T, and a nullable of each, so suspend (Char) -> String and suspend (List<String>) -> Int bind. A Throwable result binds as Task<Exception> (suspend (Int) -> Throwable is Func<int, CancellationToken, Task<Exception>>), unless it is declared narrower than RuntimeException. A lambda of four or more parameters or a Pair or IntArray payload is refused by name, and the member is then not generated. A plain (T) -> R lambda on a suspend fun binds separately; see A plain lambda on a suspend or Flow member. A suspend lambda parameter on a data class copy, an enum member or a value class member is untested.

  • Null. Passing null for a non-nullable delegate throws ArgumentNullException before Kotlin runs, as for any other lambda parameter. A nullable one ((suspend (T) -> R)?) binds as Func<..>? and takes null, which reaches Kotlin as null; a nullable payload or result crosses as null too.

A Flow as a parameter

A Kotlin Flow<T> parameter whose element is a primitive or String binds as IAsyncEnumerable<T>. Kotlin collects it as a cold flow: it asks your enumerable for a fresh enumerator on each collect and pulls one element at a time, so a source never runs ahead of the Kotlin collector.

class WaterFountain(val cat: String) { suspend fun totalDrops(drops: Flow<Int>): Int { var total = 0 drops.collect { drop -> total += drop } return total } } suspend fun countDrops(drops: Flow<Int>): Int = drops.count()
public Task<int> TotalDropsAsync(global::System.Collections.Generic.IAsyncEnumerable<int> drops, CancellationToken cancellationToken = default) public static Task<int> CountDropsAsync(global::System.Collections.Generic.IAsyncEnumerable<int> drops, CancellationToken cancellationToken = default)
using System.Runtime.CompilerServices; static async IAsyncEnumerable<int> Drip(int count, [EnumeratorCancellation] CancellationToken token = default) { for (int drop = 1; drop <= count; drop++) { await Task.Delay(5, token); yield return drop; } } using var fountain = new WaterFountain("Oreo"); int total = await fountain.TotalDropsAsync(Drip(4)); // 10

The element is a Boolean, any signed or unsigned integer type, Float, Double or String. A KotlinFlow<T> that Kotlin handed out is an IAsyncEnumerable<T>, so it can be passed straight back in.

Where it binds

The parameter binds on a class suspend fun, a suspend fun on a sealed arm, a top-level suspend fun, an object member, an extension, and on a non-suspend method or constructor that stores the flow for later. A Flow-returning member that takes a Flow enumerates nothing until C# collects the result it returns. A stored flow can be collected any number of times, and each collect is its own enumeration:

class Dripper(private val drops: Flow<Int>) { suspend fun drain(): String = drops.toList().joinToString(",") }
public Dripper(global::System.Collections.Generic.IAsyncEnumerable<int> drops)
using var dripper = new Dripper(Drip(3)); await dripper.DrainAsync(); // "1,2,3" await dripper.DrainAsync(); // "1,2,3", from a second enumerator

Enumeration, disposal and errors

  • Cold. GetAsyncEnumerator runs once per Kotlin collect, so a replayed or one-shot source behaves as its own type does. Collecting the same parameter twice enumerates it twice.

  • Disposal is queued. Each enumerator's DisposeAsync runs exactly once, however the collection ends: to the end, an early exit such as take(2), a throw, or cancellation. It runs on a queued work item, so it lands shortly after the Task from the Kotlin call completes, not before. Do not assert or rely on disposal having happened at that instant. To observe an iterator's finally, signal from it and await the signal.

  • Cancellation. When Kotlin cancels the collection (the CancellationToken you passed to the call, or a timeout inside it), the token your enumerator received is cancelled. Mark the iterator with [EnumeratorCancellation] and pass the token to what it awaits. An iterator that ignores it stops at its next element, and its captured state stays alive until then.

  • Errors. A throw from the iterator, at any point in the stream, reaches the Kotlin collector as a NugetManagedException that Kotlin can catch. If Kotlin does not catch it, the outer Task faults with the original message; catch Exception rather than relying on the original type. An exception that Kotlin's own collector throws faults the outer Task with that exception, and the enumerator is still disposed.

  • Threads. The synchronous part of GetAsyncEnumerator and of each MoveNextAsync, up to its first await that does not complete synchronously, runs on the Kotlin dispatcher thread. What follows resumes on the .NET thread pool, never on the caller's SynchronizationContext.

  • Lifetime. Kotlin owns the enumerable after the call, as with a suspend lambda parameter: a stored flow, and whatever it captures, is released by a Kotlin cleaner after its owner is dropped, so release follows a garbage-collection round and not the end of the call.

  • Null. Passing null throws ArgumentNullException before Kotlin runs.

What does not bind

Each of these is skipped with a build diagnostic naming the member, and the rest of the class is unaffected.

  • StateFlow<T>, SharedFlow<T> and MutableStateFlow<T> parameters. C# has no source for a synchronous current value or a replay cache. Declare the parameter as Flow<T> and call stateIn(scope) or shareIn on it in Kotlin. A member that itself returns a StateFlow or SharedFlow does not admit a Flow parameter either; its diagnostic still reads but not <name>: Flow<Int>, which is about that host and not about Flow parameters in general.

  • Other elements. Only the primitives, String and a method's or class's own type parameter bind. Char, Throwable, an enum, an object, a collection or a nullable element is refused. A nullable Flow<T>? parameter binds as IAsyncEnumerable<T>?, and a C# null reaches Kotlin as null.

  • Enum and value class members, and a data class copy. A member of an enum or value class that takes a Flow is not generated, and a data class copy omits a Flow component.

  • Implementing the interface in C#. A Kotlin interface member that takes a Flow binds for calling, but a C# class cannot implement that interface: the build reports SKIPPED_UNIMPLEMENTABLE_INTERFACE, and passing such an implementation to Kotlin throws NotSupportedException. A member that returns a Flow is fine, see below.

Implementing suspend and Flow members in C#

A C# class can implement a Kotlin interface whose members are suspend or return a Flow, and Kotlin awaits and collects it like any other implementation. The interface declares Task<int> FeedAsync(string cat, CancellationToken cancellationToken = default) and KotlinFlow<string> Meals(); the class implements them:

public async Task<int> FeedAsync(string cat, CancellationToken cancellationToken = default) { await Task.Delay(5, cancellationToken); return 3; } public KotlinFlow<string> Meals() => KotlinFlow<string>.From(Courses()); // an IAsyncEnumerable<string>
  • The token your FeedAsync receives is cancelled when the Kotlin coroutine is, for example by withTimeoutOrNull. An exception it throws is catchable in Kotlin.

  • KotlinFlow<T>.From(IAsyncEnumerable<T>) is cold: every Kotlin collect enumerates the source again, and a Kotlin take(2) disposes the enumeration early.

  • For a StateFlow, SharedFlow or mutable member, hand back a real Kotlin flow from KotlinMutableStateFlow<T>.Create(initial) or KotlinMutableSharedFlow<T>.Create(replay). Kotlin reads and writes the same flow you hold, so flow.Value = 23 in C# is seen by thermostat.level.value in Kotlin. A wrapper that holds no Kotlin flow of its own, such as a flow read from a Kotlin-backed object, can be returned from these members as it is.

  • A suspend member that returns a Flow is Task<KotlinFlow<T>>.

See Implementing a Kotlin interface in C# for a full class and the element types a Flow member can carry.

A plain lambda on a suspend or Flow member

A plain (non-suspend) lambda parameter on a suspend member, or on a member that returns a Flow, binds as a C# delegate. The delegate may be called any number of times, from other threads, while the coroutine runs:

suspend fun run(steps: Int, onStep: (Int) -> Unit): Int { /* calls onStep per step */ } fun watch(laps: Int, onSeen: (Int) -> Unit): Flow<Int> { /* calls onSeen per lap */ } suspend fun <T> relay(value: T, pass: (T) -> T): T { /* ... */ }
public Task<int> RunAsync(int steps, Action<int> onStep, CancellationToken cancellationToken = default) public KotlinFlow<int> Watch(int laps, Action<int> onSeen) public Task<T> RelayAsync<T>(T value, Func<T, T> pass, CancellationToken cancellationToken = default)
var seen = new List<int>(); int steps = await treadmill.RunAsync(3, seen.Add); // seen == [0, 1, 2]

It binds on every owner that has a suspend or Flow route: a class, a sealed arm, a generic class, the top level, an object, a companion, an extension, an enum member and a value class. Payloads are primitives, String, an exported class in or out, and a member's own or the class's T.

Your delegate stays registered until Kotlin is done with it, not until the first call returns:

  • suspend member. Released when the Task completes, faults or is cancelled. Cancellation before the coroutine starts counts. Cancel through the CancellationToken as usual.

  • Flow member. Each enumeration registers the delegate afresh and releases it when that enumeration completes, faults, is cancelled or is abandoned with break or DisposeAsync(). Collecting twice calls the delegate during both.

  • suspend member returning a Flow. The delegate lives as long as the returned KotlinFlow, so you can collect it again. Dispose the flow (or let it be collected) to release the delegate. If the call faults, is cancelled or returns null, it is released at once.

Passing null throws ArgumentNullException naming the parameter, immediately on a suspend member and at the first enumeration on a Flow member. An exception thrown by your delegate faults the Task or the enumeration with that same exception. If Kotlin stores the lambda and calls it after the coroutine has ended, a Unit lambda is dropped silently and a returning one reports an ObjectDisposedException to Kotlin, as for a per-call lambda.

Cancellation and disposal

Disposing the owning object cancels any coroutine it started, including children launched with coroutineScope { launch { ... } }:

class CatNapService { suspend fun longNap(): String { delay(10.seconds) return "refreshed after nap" } }
Task<string> task; using (var service = new CatNapService()) { task = service.LongNapAsync(); await Task.Delay(50); } // Dispose() cancels the in-flight nap // task throws TaskCanceledException

Every generated async method also accepts an explicit CancellationToken, independent of Dispose(); cancelling it fails only that call, not sibling calls on the same object:

var cts = new CancellationTokenSource(); Task<string> napTask = service.LongNapAsync(cts.Token); cts.Cancel(); // napTask throws TaskCanceledException

If the Kotlin function itself declares a parameter literally named cancellationToken, that name stays on the user's own parameter and the generated token is renamed cancellationToken_ instead (see C# names).

DisposeAsync() drains instead of cancelling: it waits for in-flight coroutines to finish naturally before releasing the handle.

The exception is a running Flow collection: DisposeAsync() cancels it rather than waiting, since a reader that stopped reading without disposing its enumerator would otherwise block the drain forever. Dispose your enumerators (await foreach does) so the Kotlin side sees a clean cancel.

var service = new CatNapService(); Task<string> quickNap = service.QuickNapAsync(); await service.DisposeAsync(); // waits for quickNap, then releases string result = await quickNap;

A flow wrapper you read from a property, or from a method that returns a read-only flow, throws ObjectDisposedException naming the owner if you use it after the owner is disposed, as a method call would. A collection already running when you dispose still ends cleanly. A held or awaited MutableStateFlow or MutableSharedFlow keeps reading and writing after the owner is disposed, but a new collection or EmitAsync on it throws.

A wrapper you drop without disposing is not cancelled mid-flight: a pending suspend call, a running await foreach and a held or awaited StateFlow keep the owner alive until they finish, and the GC releases it afterwards (see Classes and objects).

A class that both implements an exported interface and has suspend/Flow members still gets Dispose()/DisposeAsync() on its own base list, alongside the interface:

public class NapPod : INapper, IDisposable, IAsyncDisposable, INugetHandle

Hold it as IAsyncDisposable through a field, a cast, or await using, and it resolves correctly.

An interface that itself declares a suspend/Flow/StateFlow member is IAsyncDisposable too, and every implementer's version of that member is callable through the interface-typed reference itself, not just through a concrete class; see Interfaces, abstract classes and sealed classes: Async members on an interface.

A class with a Kotlin superclass

One coroutine scope exists per instance, owned by whichever class is first, top to bottom, to declare a suspend or Flow/StateFlow-returning member. It does not have to be the base:

open class SunShelf(val spot: String) { fun describeLounge(): String = "$spot is warm" } class NapLounge(spot: String) : SunShelf(spot) { suspend fun rest(cat: String): String { delay(10.milliseconds) return "$cat napped on $spot" } }
await using var lounge = new NapLounge("the windowsill"); Assert.Equal("Oreo napped on the windowsill", await lounge.RestAsync("Oreo")); // SunShelf itself is IDisposable but not IAsyncDisposable: it declares no async member.

The other direction works the same way: if the base declares the first async member, a subclass that declares none of its own is not IAsyncDisposable a second time, it inherits Dispose()/DisposeAsync() from the owner, and calling the member through either a base- or subclass-typed reference reaches the one scope:

open class Feeder(val bowls: Int) { open suspend fun fill(): String { /* ... */ } } class TimedFeeder(bowls: Int, val hour: Int) : Feeder(bowls) { override suspend fun fill(): String { /* ... */ } // not re-declared in C#; dispatches dynamically suspend fun schedule(): Int { /* ... */ } }
Feeder feeder = new TimedFeeder(2, 7); await feeder.FillAsync(); // reaches TimedFeeder.fill await ((TimedFeeder)feeder).ScheduleAsync(); await feeder.DisposeAsync(); // drains both, one scope

An override suspend fun is not declared as a second C# method: Feeder.FillAsync is the only one, and Kotlin's own dynamic dispatch reaches the override. An abstract class that declares the first async member declares DisposeAsync abstractly, and each concrete subclass carries the body as override, so await using and a cast to IAsyncDisposable both work through the abstract type. An abstract suspend fun with no body works the same way: the abstract class carries the one ...Async method and the scope, and calling it reaches the concrete subclass's body.

A class whose only suspend/Flow member was refused (see Limitations) gets no scope and no IAsyncDisposable anywhere in its chain, since nothing on it uses one.

Flow<T>

val mealAnnouncements: Flow<String> = flow { emit("$catName is hungry") delay(50.milliseconds) emit("$catName is eating") delay(50.milliseconds) emit("$catName is full") }
using var feeder = new CatFeeder("Oreo"); await foreach (var item in feeder.MealAnnouncements) items.Add(item);

Flow<T> becomes KotlinFlow<T> : IAsyncEnumerable<T>. It is cold: each await foreach re-runs the Kotlin flow from the start. WithCancellation stops the enumeration early.

A var flow property

A public var of type Flow<T>, StateFlow<T>, SharedFlow<T>, MutableStateFlow<T> or MutableSharedFlow<T> binds a property with a setter. It works on a class, an interface, a sealed base or arm and a generic class. Assigning replaces the Kotlin flow. The setter takes any flow: one a Kotlin member returned, or one you made in C#.

class CatFeeder(val name: String) { var source: Flow<Int> = flowOf(1) }
feeder.Source = other.Source; // another feeder's Kotlin flow feeder.Source = KotlinFlow<int>.From(Counting()); // a cold flow made in C#

A flow made with KotlinFlow<T>.From is cold, so each Kotlin collect enumerates your source again. A hot property takes a Kotlin flow, or one you made with KotlinMutableStateFlow<T>.Create or KotlinMutableSharedFlow<T>.Create. A val, a private set and an internal set bind get-only.

On an object, a companion, an enum and the top level (static owners) a var flow is settable the same way, and an enum body's setter is a Set<P> method.

A hot flow cannot be taken as a parameter, so a Flow<T> parameter plus stateIn(scope) or shareIn in Kotlin is the way to turn a cold C# source into a hot one.

Backpressure

A slow await foreach body slows the Kotlin producer, as a slow collect would. At most one item sits unread on the C# side, and the flow body parks inside the next emit, so a side effect placed before that emit runs one item ahead of what your loop has seen. A cancel or break hands out at most that one buffered item. There is no C# prefetch setting; call buffer(n) on the Kotlin flow if you want a larger lead.

await foreach (var treat in conveyor.Belt) await Task.Delay(1000); // the Kotlin producer waits here instead of racing ahead

The same applies to StateFlow<T>, SharedFlow<T> and a Flow returned from suspend.

An element type that is an interface, or a List<T>/Set<T>/Map<K, V>, is spelled and read exactly like the same type at a property or suspend return, described above. An interface that appears nowhere else in your exported API, only as a Flow/StateFlow element or a suspend result, still gets its backing class like any other interface return.

If an emitted element (or a suspend result) cannot be materialized on the C# side, await foreach throws instead of aborting the process: the failure faults the IAsyncEnumerable<T> (or the Task) and cancels the Kotlin side of the collection. The failed item's handle is released, not leaked; see ADR-161.

Nullable element Flow<T?>

A nullable element carries its ? onto KotlinFlow<T?>, and a null emission is a genuine item, not the end of the stream:

fun petsPassingBy(): Flow<Pet?> = flow { emit(strayPet()) emit(null) emit(strayPet()) } fun napsPassingBy(): Flow<Int?> = flowOf(1, null, 3)
public KotlinFlow<global::TestLibrary.Cat.IPet?> PetsPassingBy() public KotlinFlow<int?> NapsPassingBy()
using var window = new PassersBy(); var seen = new List<int?>(); await foreach (int? naps in window.NapsPassingBy()) seen.Add(naps); // seen: [1, null, 3]

An interface element still resolves a stored C#-implemented instance the same way the non-null form does; only a genuinely absent element is null. A nullable List, Set or Map element (Flow<List<Int>?>) binds the same way, as KotlinFlow<IReadOnlyList<int>?>; null is never an empty collection. This is the element's own nullability (Flow<T?>); the whole stream being absent is the next section.

Nullable member Flow<T>?

A property or method whose whole Flow can be absent binds as KotlinFlow<T>?, null when the Kotlin member is null. Check it before enumerating:

val specialDiet: Flow<String>? get() = if (catName == "Mylo") null else flow { emit("$catName: salmon") } fun visitorsAfter(meals: Int): Flow<Cat>? = if (meals == 0) null else flow { emit(Cat("Mylo")) }
KotlinFlow<string>? diet = oreo.SpecialDiet; // null for Mylo KotlinFlow<Cat>? visitors = feeder.VisitorsAfter(0); // null

A suspend fun returning Flow<T>? awaits to Task<KotlinFlow<T>?>, on a class or at top level; dispose the acquired holder when it is not null. SharedFlow<T>? members behave the same way. The same holds on every owner: a class, the top level, an object, a companion (named or not), an enum body and its companion, and a value class, generic T included:

fun purrsIfHome(home: Boolean): Flow<Int>? = if (home) flowOf(1, 2) else null
KotlinFlow<int>? purrs = Absences.PurrsIfHome(false); // null

A method is called once to decide presence and again when you enumerate, so it must return the same answer both times. Keep a nullable-returning member free of side effects.

SharedFlow<T>

class CatBulletin(private val name: String) { val editions: SharedFlow<Int> = ... val headlines: MutableSharedFlow<String> = MutableSharedFlow(replay = 2) val moods: MutableSharedFlow<Mood> = MutableSharedFlow(replay = 1) fun editionReport(): SharedFlow<Int> = editions fun headlineDesk(): MutableSharedFlow<String> = headlines suspend fun awaitHeadlineDesk(): MutableSharedFlow<String> = headlines suspend fun latestSightings(): SharedFlow<Cat> = ... fun publish(headline: String, edition: Int) { ... } }
using var bulletin = new CatBulletin("Oreo"); bulletin.Publish("found the treat jar", 1); await foreach (string headline in bulletin.Headlines) { Console.WriteLine(headline); // "Oreo: found the treat jar", replayed from the cache break; } IReadOnlyList<int> editions = bulletin.Editions.ReplayCache; // [1], no subscription using KotlinSharedFlow<Cat> sightings = await bulletin.LatestSightingsAsync(); // dispose it

SharedFlow<T> becomes KotlinSharedFlow<T> : KotlinFlow<T>, and a suspend return becomes Task<KotlinSharedFlow<T>>. It binds at a property, a method return and a suspend return on classes, interfaces, sealed arms and top-level functions. ReplayCache is a snapshot of Kotlin's replayCache, oldest first. A method that returns a SharedFlow is called again on every read. For an object element, each read returns new wrappers that you own and dispose.

Each await foreach starts by receiving the Kotlin replay cache in order, then waits for new emissions. A SharedFlow never completes on its own, so bound the loop with break or a CancellationToken, as for StateFlow<T>. With replay = 0, an item published before you start enumerating is lost, and the bridge gives no signal that the collector has subscribed. Element types follow Flow<T>, including a nullable element (SharedFlow<String?> binds KotlinSharedFlow<string?>).

Emitting from C#

A member whose declared type is MutableSharedFlow<T>, not narrowed to SharedFlow<T> (the common _x.asSharedFlow() idiom stays read-only), surfaces as KotlinMutableSharedFlow<T>:

KotlinMutableSharedFlow<string> desk = bulletin.Headlines; await desk.EmitAsync("found the treat jar"); bool accepted = desk.TryEmit("napped on the keyboard"); // false if it would have to suspend bulletin.Moods.TryEmit(Mood.Grumpy); // an enum crosses by ordinal using KotlinStateFlow<int> subscribers = desk.SubscriptionCount; int live = subscribers.Value; // collectors subscribed right now
  • EmitAsync completes once every subscriber has taken the value, or it is buffered or replayed. It suspends only while a subscriber is busy, and an optional CancellationToken cancels it. An already-cancelled token returns a cancelled Task without emitting. Disposing the owner cancels a parked EmitAsync on a property, so its Task ends as cancelled instead of hanging.

  • TryEmit never suspends, so it is the call to use from synchronous code.

  • Writing an object element passes the wrapper for the call only; you keep ownership. A null for a non-null object element throws ArgumentNullException.

  • SubscriptionCount returns a new KotlinStateFlow<int> on every read. Dispose it; a dropped one is released by the GC.

  • A method returning MutableSharedFlow<T>, and a suspend function returning one, give you the flow itself. Keep it and dispose it, and every write lands in the same Kotlin flow:

using KotlinMutableSharedFlow<string> desk = bulletin.HeadlineDesk(); desk.TryEmit("held the desk"); // bulletin.Headlines.ReplayCache sees it using KotlinMutableSharedFlow<string> awaited = await bulletin.AwaitHeadlineDeskAsync();

The elements you can write are those a MutableStateFlow<T> can set, described in Settable .Value. That includes a value class, which emits by value: TryEmit(new CatId("oreo-1")) crosses as the underlying and Kotlin wraps it again, with the same underlyings and the same ArgumentException for default(CatId) as a MutableStateFlow of a value class. That includes a nullable Boolean, Char or enum (KotlinMutableSharedFlow<Mood?>), where null is an emit of its own. A MutableSharedFlow of a List, Set or Map (nullable too, see Nullable collection element), a ByteArray or an interface emits as well. One of an element a MutableStateFlow cannot set binds a read-only KotlinSharedFlow<T> and the build reports SKIPPED_UNSUPPORTED_INPUT. On a property the remark names the read-only C# property; expose a function that takes the element to emit it. A value class over anything the list above does not admit is not bound at all: the member is skipped by name (see value class elements). resetReplayCache() is not exposed.

StateFlow<T>

private val _energyLevel: MutableStateFlow<Int> = MutableStateFlow(100) val energyLevel: StateFlow<Int> = _energyLevel.asStateFlow()
using var tracker = new CatMoodTracker("Oreo"); int current = tracker.EnergyLevel.Value; // synchronous, no await

StateFlow<T> becomes KotlinStateFlow<T> : KotlinFlow<T>: hot, always has a current .Value, and replays that value as the first element of any new await foreach. It never completes on its own, so bound the enumeration with a CancellationToken or a break:

var cts = new CancellationTokenSource(); await foreach (var level in tracker.EnergyLevel.WithCancellation(cts.Token)) { seen.Add(level); cts.Cancel(); }

KotlinStateFlow<T> upcasts to KotlinFlow<T> and IAsyncEnumerable<T>, mirroring Kotlin's own StateFlow : Flow. T can be a sealed base class; .Value and await foreach both materialize the correct generated subclass, see Interfaces, abstract classes and sealed classes.

Data binding

To bind a StateFlow to XAML (WPF, WinUI, MAUI, Avalonia), call AsNotifying(). It returns a KotlinStateFlowObservable<T> that implements INotifyPropertyChanged and IDisposable; bind to its Value. It works on KotlinMutableStateFlow<T> too, where a C# write to Value on the flow reaches the binding.

using var tracker = new CatMoodTracker("Mylo"); // No SynchronizationContext here, so the event is raised inline KotlinStateFlowObservable<string> mood = tracker.Mood.AsNotifying(); mood.PropertyChanged += (_, e) => Console.WriteLine($"{e.PropertyName}: {mood.Value}"); tracker.SetMood("zoomies"); // raises PropertyChanged("Value") on the thread that received it // when the binding goes away mood.Dispose();
  • Value starts as the flow's current value and holds the last delivered element, so reading it costs nothing.

  • PropertyChanged is posted to SynchronizationContext.Current at the AsNotifying() call, or to the context you pass (AsNotifying(context)). With no context it is raised on the thread that received the element. Call it on the UI thread, or pass the dispatcher context.

  • Call Dispose() when the binding goes away. An adapter you drop keeps collecting and keeps the flow's owner alive for the life of the process.

  • Completion finishes after Dispose() and faults if the Kotlin collection fails.

  • A wrapper-typed element it replaces is not disposed, because you may still hold it.

Enum and value class elements

StateFlow<E>, StateFlow<E?>, Flow<E> and Flow<E?> over an enum read back as the mapped C# enum, with null staying null. A value class element reads back as its record struct the same way.

class CatMoodTracker(private val catName: String) { val temper: StateFlow<Mood> // starts SLEEPY val maybeTemper: StateFlow<Mood?> // null until sulk() fun sulk() fun moodSwings(): Flow<Mood?> = flow { emit(Mood.SLEEPY); emit(null); emit(Mood.GRUMPY) } }
using var tracker = new CatMoodTracker("Mylo"); Mood now = tracker.Temper.Value; // Mood.Sleepy tracker.Sulk(); Mood? maybe = tracker.MaybeTemper.Value; // Mood.Grumpy await foreach (Mood? mood in tracker.MoodSwings()) { // Mood.Sleepy, null, Mood.Grumpy }

The same holds for an enum dependency admitted with admit(...) and for a nested enum. Generic classes instantiated at an enum read and write it too (Box<Mood>.Value, new Box<Mood>(Mood.Calm)).

A non-null MutableStateFlow<E> of an enum is settable, on a class property, a function return, a sealed-arm member and a class suspend return. The write crosses as the entry's ordinal, so an ordinal that no longer names an entry (a C# cast such as (Mood)99) throws KotlinException and the flow keeps its value:

class CatMoodTracker(private val catName: String) { val outlook: MutableStateFlow<Mood> = MutableStateFlow(Mood.SLEEPY) }
tracker.Outlook.Value = Mood.Grumpy;

A nullable element (MutableStateFlow<Mood?>) is settable too on a property and a function return, and null clears it. null is a write of its own, never entry 0; an out-of-range ordinal throws as above.

A MutableStateFlow of a value class is settable on the same routes, and CompareAndSet and the Update family work on it. The write crosses as the underlying value and Kotlin wraps it again, so the value class's init runs on every write:

class CatMoodTracker(private val catName: String) { val chipId: MutableStateFlow<CatId> = MutableStateFlow(CatId("oreo-chip")) // value class CatId(val id: String) val spareChipId: MutableStateFlow<CatId?> = MutableStateFlow(null) }
tracker.ChipId.Value = new CatId("oreo-9"); bool swapped = tracker.ChipId.CompareAndSet(new CatId("oreo-9"), new CatId("mylo-2")); // true tracker.SpareChipId.Value = new CatId("spare-1"); tracker.SpareChipId.Value = null;
  • CompareAndSet, Update, UpdateAndGet and GetAndUpdate compare with Kotlin equals on the underlying value, never C# record-struct equality, so a freshly built CatId matches the stored one and a stale one does not.

  • A nullable element (MutableStateFlow<CatId?>) is settable too and null clears it.

  • default(CatId) has no Id, so writing it, or passing it to CompareAndSet, throws ArgumentException in C# before anything crosses. This applies to a value class over a String or an object.

  • The underlying can be a String, a primitive, a Char, an enum, a sealed type, an exported class or object, or (with its own box pair) an Instant, Duration, Uuid, ByteArray, collection, plain interface or another value class, so MutableStateFlow<PostPrint> reads and writes. A bare Instant, Duration or Uuid element is settable too, on its ticks or text wire. A value class over a Throwable has no C# record struct, so its member is skipped by name (SKIPPED_UNSUPPORTED_TYPE). A generic value class (Sticker<Int>) and one over a nullable underlying (value class Nick(val name: String?)) are flow elements too, and null stays distinct from Nick(null); see A generic value class at an erased position.

Settable .Value on MutableStateFlow<T>

A member whose declared type is MutableStateFlow<T>, not narrowed to StateFlow<T> (the common private val _x = MutableStateFlow(...)/val x: StateFlow<T> = _x.asStateFlow() idiom stays get-only), surfaces as KotlinMutableStateFlow<T> : KotlinStateFlow<T> with a settable .Value:

val treatCount: MutableStateFlow<Int> = MutableStateFlow(0)
using var tracker = new CatMoodTracker("Mylo"); tracker.TreatCount.Value = 7;

The write lands in Kotlin synchronously and is visible to any live collector as its next (conflated) emission. Writing is safe from any thread. Kotlin's MutableStateFlow.value setter calls equals on the previous value to decide whether to conflate; if that equals throws, the throw propagates out of the C# write as KotlinInvalidOperationException, unlike the get-only .Value read, which never throws.

A nullable element is settable too, and null is a valid write (a nullable Int crosses as presence plus value, so null never lands as 0):

val napMinutes: MutableStateFlow<Int?> = MutableStateFlow(null) private var _diary: MutableStateFlow<String>? = null val diary: MutableStateFlow<String>? get() = _diary
tracker.NapMinutes.Value = 15; tracker.NapMinutes.Value = null; KotlinMutableStateFlow<string>? diary = tracker.Diary; // null until Kotlin creates it if (diary is not null) diary.Value = "day two";

A MutableStateFlow<T>? property is settable once it is present. If Kotlin has dropped the flow since you read it, the write throws KotlinInvalidOperationException instead of being ignored. A function returning MutableStateFlow<T>? stays read-only.

Boolean?, Char? and a nullable enum are settable like any other nullable element, so null, false and true are three different writes and a non-ASCII char survives:

tracker.Purring.Value = false; // MutableStateFlow<Boolean?> tracker.Purring.Value = null; tracker.Initial.Value = '한'; // MutableStateFlow<Char?>

A nullable element is settable on a suspend fun return too; only a nullable holder (MutableStateFlow<T>?) stays read-only there.

Nullable collection element

A MutableStateFlow or MutableSharedFlow over a nullable List, Set or Map (MutableStateFlow<List<Int>?>) is settable like a non-null collection. null crosses as no collection, so C# can tell null from an empty one, and so can Kotlin. It binds on a class, an object, the top level, a held return and an awaited return, and a plain Flow<Map<String, Int>?> yields null as one of its items. CompareAndSet, EmitAsync, TryEmit and ReplayCache work with null:

class SockDrawer { val socks: MutableStateFlow<List<Int>?> = MutableStateFlow(null) val labels: MutableSharedFlow<Set<String>?> = MutableSharedFlow(replay = 1) val tallies: Flow<Map<String, Int>?> = flowOf(null, mapOf("Oreo" to 3)) suspend fun awaitSocks(): MutableStateFlow<List<Int>?> = socks }
var drawer = new SockDrawer(); using KotlinMutableStateFlow<IReadOnlyList<int>?> socks = drawer.Socks; socks.Value = [3, 4]; socks.CompareAndSet([], [1]); // false: [3, 4] is neither empty nor null socks.Value = null; // Kotlin sees null, not an empty list using KotlinMutableSharedFlow<IReadOnlySet<string>?> labels = drawer.Labels; labels.TryEmit(null); // an emit of its own; ReplayCache holds the null using KotlinMutableStateFlow<IReadOnlyList<int>?> awaited = await drawer.AwaitSocksAsync();

The collection you write is copied for the call, as for a non-null collection, and the holder is disposed like any other MutableStateFlow.

An element that is a generic class instantiation (Box<String>) or a generic sealed type (Outcome<Int>) is settable like any other class element, nullable (Box<String>?) included. The box you write is borrowed, so it stays yours to dispose:

class BoxDisplay { val front: MutableStateFlow<Box<String>> = MutableStateFlow(Box("first")) val verdict: MutableStateFlow<Outcome<Int>> = MutableStateFlow(Outcome.Ok(1)) }
using var display = new BoxDisplay(); using var second = new Box<string>("second"); display.Front.Value = second; using var refused = new Outcome.Err<int>("no dinner"); display.Verdict.Value = refused;

A MutableStateFlow of a generic value class (MutableStateFlow<Sticker<Int>>) is settable too, and the struct's init runs on every write; see A generic value class at an erased position.

Reassigning the whole MutableStateFlow<T> member itself (a var holding a different flow instance) is not supported; only writes through .Value are.

Atomic updates

CompareAndSet(expect, update) swaps only if the current value equals expect (Kotlin equals) and returns whether it did. Update, UpdateAndGet and GetAndUpdate retry it until the transform lands, like Kotlin's update { }, so concurrent callers lose nothing:

tracker.TreatCount.Value = 3; bool moved = tracker.TreatCount.CompareAndSet(expect: 3, update: 4); Parallel.For(0, 200, _ => tracker.TreatCount.Update(n => n + 1)); int now = tracker.TreatCount.UpdateAndGet(n => n * 2); // the new value int before = tracker.TreatCount.GetAndUpdate(n => n + 1); // the previous value

They work on every settable form above, nullable elements and members included. Things that differ from a plain C# CAS:

  • An object element is compared with Kotlin equals, which is identity for a plain class. A wrapper read back from .Value is the same Kotlin instance, so it matches; a second Cat with the same fields does not. A data arm of a sealed type is equal by value, so a C#-built new Outcome.Ok<int>(1) matches the Ok(1) Kotlin holds.

  • A throwing Kotlin equals surfaces as KotlinInvalidOperationException, never as false.

  • An absent MutableStateFlow<T>? throws on CompareAndSet, as the setter does.

  • The transform may run more than once under contention, so keep it free of side effects. An exception it throws propagates unchanged and leaves the value alone.

  • Update never disposes the value passed to the transform, and an object element's retry loop leaves the wrapper it reads to the GC. Dispose any object wrapper you create or read yourself.

Nullable StateFlow<T?> and StateFlow<T>?

A StateFlow can be nullable in the element (StateFlow<T?>) or the member itself (StateFlow<T>?), independently, and the two compose:

val nickname: StateFlow<String?> = _nickname.asStateFlow() // nullable element val maybeMood: StateFlow<String>? get() = _maybeMood?.asStateFlow() // nullable member
public KotlinStateFlow<string?> Nickname { get; } // .Value and every emission are string? public KotlinStateFlow<string>? MaybeMood { get; } // null until the member exists

A null element crossing await foreach is a genuine emission, not the end of the stream. Writes to a nullable MutableStateFlow are covered in Settable .Value. A suspend fun returning a nullable StateFlow binds as described above.

A flow inside a generic class

A Flow<E> or StateFlow<E> can be the type argument of an exported generic class, at every position a generic class instantiation binds, including a top-level function return. box.Value is a KotlinFlow<E> or KotlinStateFlow<E> that you collect and dispose like any other flow holder:

class Box<T>(val value: T) class BoxRadio { private val temper = MutableStateFlow(Mood.SLEEPY) fun ticks(): Box<Flow<Int>> = Box(flowOf(1, 2, 3)) fun mood(): Box<StateFlow<Mood>> = Box(temper) }
using var radio = new BoxRadio(); using Box<KotlinFlow<int>> box = radio.Ticks(); using KotlinFlow<int> ticks = box.Value; await foreach (int tick in ticks) Console.WriteLine(tick); using Box<KotlinStateFlow<Mood>> moodBox = radio.Mood(); using KotlinStateFlow<Mood> mood = moodBox.Value; Mood current = mood.Value;

Elements follow the same rules as a Flow member, so an enum, a value class, a collection, an interface, a nullable element and ByteArray all work. Three things differ from a Flow member:

  • Each .Value read returns a new holder over the same Kotlin flow. Dispose every one; each await foreach still re-runs a cold flow from the start.

  • The holder owns the scope its collections run on. Disposing the holder cancels them. Disposing the object that produced the box neither cancels them nor waits for them, so a collection that never completes does not hang the owner's DisposeAsync. A holder you never dispose keeps its collection running until it is finalized.

  • It is read-only. Box<MutableStateFlow<T>> binds as a read-only KotlinStateFlow<T> and the build names the dropped setter. Handing a holder back into Kotlin (new Box<KotlinFlow<int>>(ticks)) throws NotSupportedException.

Box<SharedFlow<E>> binds as KotlinFlow<E>. A flow of a flow (Box<Flow<Flow<Int>>>), a lambda element, a star projection, an open Box<Flow<T>>, and a flow argument of a generic sealed type (Outcome<Flow<Int>>) are skipped with a diagnostic naming the member.

A Flow with a type parameter

A generic class's own Flow<T>, StateFlow<T> and MutableStateFlow<T> members bind as KotlinFlow<T>, KotlinStateFlow<T> and KotlinMutableStateFlow<T>, and the class is IAsyncDisposable like any class with a Flow member. A Flow-returning method can also declare its own T, on a class with or without type parameters of its own.

class Brook<T>(val seed: T) { val updates: Flow<T> = flowOf(seed, seed) val level: MutableStateFlow<T> = MutableStateFlow(seed) val gaps: Flow<T?> = flowOf(seed, null, seed) fun flowing(times: Int): Flow<T> = flow { repeat(times) { emit(seed) } } } class Creek { fun <T> stream(value: T): Flow<T> = flowOf(value) fun <T : Enum<T>> crown(medal: T): Flow<T> = flowOf(medal) fun <T> rally(pet: T): Flow<String> where T : Furry, T : Tricky = flowOf(pet.name) }
public KotlinFlow<T> Updates { get; } public KotlinMutableStateFlow<T> Level { get; } public KotlinFlow<T?> Gaps { get; } public KotlinFlow<T> Flowing(int times) public KotlinFlow<T> Stream<T>(T value) public KotlinFlow<T> Crown<T>(T medal) where T : struct, global::System.Enum public KotlinFlow<string> Rally<T>(T pet) where T : IFurry, ITricky
await using var brook = new Brook<string>("Mylo"); await foreach (string name in brook.Updates) { /* "Mylo", "Mylo" */ } brook.Level.Value = "Oreo"; // a MutableStateFlow<T> is writable var creek = new Creek(); await foreach (int n in creek.Stream(4)) { /* 4 */ }
  • An element is the same kind of value T is anywhere else: a builtin, a string or a wrapper comes out as itself, and each wrapper element is yours to dispose.

  • A Flow<T> whose T is unconstrained can emit null, and Flow<T?> and Brook<string?> deliver it as a genuine item rather than ending the stream.

  • A T you pass to a method (Stream<T>(T value)) is boxed as in a generic method. A type argument outside the Kotlin bound faults the stream when you enumerate it, not the call.

  • MutableStateFlow<T> is writable when T has at most one bound. A T with several bounds, or T : Enum<T>, binds read-only as KotlinStateFlow<T>.

  • Cancelling an enumeration ends it cleanly, as on any other flow.

Not every shape binds:

  • A var flow property is settable, as on any class.

  • A Flow<T> or suspend lambda parameter that mentions T binds too.

A type parameter in a Flow or suspend lambda parameter

A method's own T, or a generic class's T, can be the element of a Flow<T> parameter or the payload and result of a suspend lambda. T is inferred from the arguments, as on any generic method.

class Siphon { suspend fun <T> gulp(source: Flow<T>): List<T> = source.toList() suspend fun <T> brew(value: T, step: suspend (T) -> T): T = step(value) suspend fun <T> tip(items: List<T>, cup: suspend (T) -> Unit): Int { /* ... */ } suspend fun <T : Enum<T>> retint(tint: T, step: suspend (T) -> T): String = step(tint).name } class Cistern<T>(val seed: T) { suspend fun gulp(source: Flow<T>): List<T> = listOf(seed) + source.toList() suspend fun brew(step: suspend (T) -> T): T = step(seed) }
public Task<IReadOnlyList<T>> GulpAsync<T>(IAsyncEnumerable<T> source, CancellationToken cancellationToken = default) public Task<T> BrewAsync<T>(T value, Func<T, CancellationToken, Task<T>> step, CancellationToken cancellationToken = default) public Task<int> TipAsync<T>(IReadOnlyList<T> items, Func<T, CancellationToken, Task> cup, CancellationToken cancellationToken = default) public Task<string> RetintAsync<T>(T tint, Func<T, CancellationToken, Task<T>> step, CancellationToken cancellationToken = default) where T : struct, Enum
using var siphon = new Siphon(); using var oreo = new Tabby("Oreo", 3); using var mylo = new Tabby("Mylo", 5); IReadOnlyList<Tabby> drank = await siphon.GulpAsync(Pour(oreo, mylo)); // Pour yields from an async iterator using Tabby brewed = await siphon.BrewAsync(oreo, (cat, _) => Task.FromResult(cat)); using var cistern = new Cistern<int>(1); await cistern.GulpAsync(Pour(2, 3)); // 1, 2, 3
  • A builtin, a string, a wrapper, a nullable T and an enum or multi-bound T all work, on a class, a generic class and a generic sealed arm. An explicit Flow<T?> under a T : Any bound drops the null elements in Kotlin.

  • Wrappers follow the rules of the matching non-generic shape. A wrapper element your source yields is borrowed, so you still dispose it. A wrapper payload your suspend lambda receives is yours to dispose. A wrapper the lambda returns, borrowed or fresh, needs no extra step: Kotlin takes its own reference. Each wrapper Kotlin returns in a List<T> is yours to dispose.

  • The enumerator lifetime, cancellation and error rules of a Flow parameter and a suspend lambda parameter apply unchanged.

  • A suspend member that also takes a plain (non-suspend) lambda binds; see A plain lambda on a suspend or Flow member.

Parameters on Flow, StateFlow, and suspend members

A List/Set/Map, primitive, String, or class/object/sealed-type parameter on a Flow-, StateFlow-, or suspend-returning member crosses the same way it does anywhere else, spelled exactly as the same member's return position spells it:

fun watch(observation: Observation.Alive): StateFlow<String> { /* ... */ } suspend fun forget(ids: Set<String>): Int { /* ... */ }
public KotlinStateFlow<string> Watch(global::TestLibrary.Cat.Observation.Alive observation) public Task<int> ForgetAsync(IReadOnlySet<string> ids, CancellationToken cancellationToken = default)

A nullable primitive, Char, Boolean, or String parameter on these same members carries its ? through as int?/char?/bool?/string?, and null reaches Kotlin as null:

suspend fun countNaps(limit: Int?): String { /* ... */ } fun snacks(limit: Int?): Flow<String> { /* ... */ }
public Task<string> CountNapsAsync(int? limit, CancellationToken cancellationToken = default) await feeder.Snacks(null); // limit reaches Kotlin as null, not 0

A nullable List, Set or Map parameter binds as the nullable C# collection, and null reaches Kotlin as null, never an empty collection:

fun servingsFor(kinds: List<String>?, cats: List<Cat>?): Flow<String> suspend fun tallyFor(kinds: List<String>?, cats: List<Cat>?): String
public KotlinFlow<string> ServingsFor(IReadOnlyList<string>? kinds, IReadOnlyList<Cat>? cats) public Task<string> TallyForAsync(IReadOnlyList<string>? kinds, IReadOnlyList<Cat>? cats, CancellationToken cancellationToken = default)

An enum parameter binds too, by ordinal ((int)x in C#, Q.entries[x] in Kotlin):

fun meows(asked: Hunger, hunger: Hunger = if (bowl > 30) Hunger.PECKISH else Hunger.STARVING): Flow<String> = flow { emit("$asked|$hunger") }
public KotlinFlow<string> Meows(Hunger asked, Hunger? hunger = null)

Default arguments on these parameters

A default on a scalar, String, or enum parameter here widens the same way a constructor or method default does: a non-nullable type widens to its nullable C# form (null means unset), an already-nullable one widens to KotlinOptional<T>, and omitting the argument runs the Kotlin default:

class Dinnerbell(val bowl: Int) { suspend fun feed(cat: String, portion: Int = bowl + ++served, treats: Int? = 5): String = "$cat|$portion|$treats" }
public Task<string> FeedAsync(string cat, int? portion = null, Optional<int?> treats = default, CancellationToken cancellationToken = default);
using var bell = new Dinnerbell(10); await bell.FeedAsync("Oreo"); // portion and treats: Kotlin evaluates bowl + ++served and 5

A defaulted handle parameter (a class, object or sealed type) widens the same way: a non-null declared type becomes Placemat? mat = null (null means unset), and an already-nullable one becomes KotlinOptional<Placemat?> mat = default, where an explicit null stays a value. Kotlin builds the default instance; you never construct it. A defaulted non-null List, Set or Map parameter stays required; it does not widen, but a defaulted nullable one does (tags: List<String>? = null becomes KotlinOptional<IReadOnlyList<string>?> tags = default: omitted runs the Kotlin default, an explicit null arrives as null). A same-name suspend overload whose shorter C# signature would otherwise become ambiguous with the widened one (CountAsync() beside CountAsync(int?, ...), CS0121) keeps the widened parameter required-but-nullable instead of gaining = null:

class Dinnerbell { suspend fun count(): Int = -1 suspend fun count(limit: Int = 3): Int = limit * 10 }
public Task<int> CountAsync(CancellationToken cancellationToken = default); public Task<int> CountAsync(int? limit, CancellationToken cancellationToken = default); // no `= null`

A class, object or sealed handle parameter may be nullable. It binds as T? and null reaches Kotlin as null:

class Checkup(private val vet: String) { suspend fun examine(room: String, cat: Cat?): String }
public Task<string> ExamineAsync(string room, Cat? cat, CancellationToken cancellationToken = default);
using var checkup = new Checkup("Dr Purr"); await checkup.ExamineAsync("room 2", null); // Kotlin sees cat == null

This holds for suspend members, sealed-subclass members, top-level suspend functions, Flow/StateFlow members and suspend functions returning StateFlow. A nullable handle with a default value (cat: Cat? = null) is still required in C#, so pass null explicitly.

Instant, Duration, Uuid, ByteArray, a plain interface and a value class over any bound underlying bind at these positions too, as a parameter, a result, a flow element and a suspend extension receiver, each on the wire the ordinary route uses. Any other generic parameter (Pair<A, B>, Array<T>, a plain (T) -> R lambda; your own Box<String> binds as a handle) is not supported at these positions and is skipped with a diagnostic naming the member. A suspend lambda parameter is the one lambda that binds, and a Flow<T> of a primitive or String is the one generic type that does. Pass an enum, or a class/object/sealed handle, List/Set/Map or primitive/String (each of these nullable or not) instead, or split the parameter across separate members. A Throwable, Exception or RuntimeException parameter does bind, as a System.Exception (see Throwable values).

Instant, Duration, Uuid, ByteArray, interfaces and value classes

A suspend or Flow member takes and returns Instant, Duration, Uuid, ByteArray, a plain interface or a value class over any of them as DateTimeOffset, TimeSpan, Guid, byte[], the interface and the record struct, nullable too. A Flow or StateFlow element, a MutableStateFlow or MutableSharedFlow write, an awaited StateFlow and the receiver of a suspend extension or value-class member work the same way. Each type crosses as it does at an ordinary position: ticks, text, a copied array, the interface bridge, or the value class's underlying.

class Mailslot(val owner: String) { suspend fun stamp(at: Instant): Instant = at + 1.minutes suspend fun scan(pads: ByteArray): ByteArray = pads + 9.toByte() } value class PostPrint(val pads: ByteArray) { suspend fun padCount(): Int = pads.size } class PostBoard { val lastStamp: MutableStateFlow<Instant> = MutableStateFlow(OFFICE_POSTMARK) val lastPrint: MutableStateFlow<PostPrint> = MutableStateFlow(PostPrint(byteArrayOf(1))) } suspend fun ByteArray?.padsOrNone(): Int = this?.size ?: -1
await using var slot = new Mailslot("Oreo"); DateTimeOffset next = await slot.StampAsync(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000)); int pads = await new PostPrint(new byte[] { 1, 2, 3 }).PadCountAsync(); // 3 int none = await ((byte[]?)null).PadsOrNoneAsync(); // -1 using var board = new PostBoard(); using KotlinMutableStateFlow<DateTimeOffset> stamp = board.LastStamp; stamp.Value = DateTimeOffset.FromUnixTimeSeconds(1_700_000_600); // crosses as ticks using KotlinMutableStateFlow<PostPrint> print = board.LastPrint; print.Value = new PostPrint(new byte[] { 1, 2, 3, 4 }); // re-wrapped in Kotlin
  • A value class's init runs in Kotlin at the crossing. As a parameter it is boxed before the call, so a failing init throws KotlinArgumentException from the call itself, before any Task exists. As the receiver of a value class's own suspend member it faults the Task with KotlinArgumentException.

  • default(PostPrint) has no bytes. Passing it, writing it to a holder, or calling a suspend member on it throws ArgumentException in C#.

  • A value class over a nullable String (value class PostNote(val text: String?)) keeps an absent PostNote? (null) apart from PostNote(null) (a struct whose Text is null).

  • A ByteArray or collection argument is copied for the call. Mutating your array afterwards does not reach Kotlin.

  • A MutableStateFlow or MutableSharedFlow of a bare ByteArray, collection or interface is settable: each write mints the handle for the call and releases it afterwards, and Kotlin keeps its own copy (a C#-implemented interface is held through its bridge).

Limitations

  • Emit, TryEmit, ReplayCache, and SubscriptionCount on MutableStateFlow<T> are not exposed (CompareAndSet and Update are; see Atomic updates).

  • A suspend fun returning MutableStateFlow<T>?, and a non-suspend function returning MutableStateFlow<T>?, bind a read-only holder. The build names each with SKIPPED_UNSUPPORTED_INPUT.

  • A StateFlow<T>, SharedFlow<T> or MutableStateFlow<T> as a function parameter is not supported; see what does not bind. As a generic type argument it binds read-only.

  • A top-level non-suspend fun f(): Flow<T>? and an object or companion Flow<T>? member are skipped with a diagnostic, like their non-null forms.

  • A Pair as a Flow/StateFlow element is not supported.

  • Boolean?/Char? value elements on a nullable StateFlow are not supported.

  • suspend fun (): StateFlow<Throwable> and any MutableStateFlow<Throwable> have no binding, for the same shared-export reason. A read-only StateFlow<Throwable> property or method binds; see Throwable values.

Flow, StateFlow, and suspend dispatch are AOT- and trim-safe; see Publishing Kotlin to C#: AOT and trimming.

Last modified: 03 November 2026