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# |
|---|---|
|
|
|
|
|
|
|
|
|
|
| the same wrapper, static or extension, see below |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
coroutine cancellation |
|
structured concurrency |
|
suspend fun
From AsyncCatService.kt:
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:
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:
null stays distinct from 0 or an empty string, the same as a nullable synchronous return.
suspend fun returning a collection
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:
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):
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
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:
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).
A top-level suspend fun gets the same holder, as a static method on its file's class:
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:
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:
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:
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:
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.
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).
A builtin, a
string, a wrapper or a C# implementation of an exported interface all work asT. AList,SetorMapof the function's ownTbinds too.A type argument outside a bound C# cannot name (
T : Number) is accepted by the compiler and faults the returnedTaskwithKotlinInvalidCastException. It does not throw at the call.A C# implementation passed as
Tstays callable by Kotlin after the call returns, for the whole suspension. A wrapper you pass is borrowed and must stay alive until theTaskcompletes.Cancelling the token cancels the
Task, as on any othersuspend 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.
Kotlin owner | C# placement |
|---|---|
| static method on the object's |
companion (default or named) | static method on the owning class |
extension on any receiver a parameter binds as |
|
enum body member |
|
enum companion member | static method in |
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#.
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:
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.
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).
Differences from a class member:
There is no owner to dispose, so each
await foreachruns on its own scope and only its enumerator ends it: finish the loop,break, or pass a token toWithCancellation.A cold
Flow<T>re-runs the Kotlin function or re-reads the property on every enumeration. Enumerating oneKotlinFlow<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 readMutableStateFloworMutableSharedFlowholds the Kotlin flow itself, sousingit. Writes through it, and through a second holder of the same Kotlin flow, are shared Kotlin storage: settingPurrClock.Houris visible throughPurrClock.Alarm().A property read mints a new holder each time, so
Chimes.Bedtimeread twice gives two holders over one flow.A
varflow property is settable, as on a class. It takes a Kotlin flow, aKotlinFlow<T>.Fromsource for a cold one, or aCreated 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 aSet<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 untouchedAn enum's flow property (a body
val, a constructorval, or anabstract valeach entry overrides) is an extension property on{Enum}Extensionsof every flow kind. Each entry keeps its own storage, soTail.Oreo.HeightandTail.Mylo.Heightare two differentMutableStateFlows, and an overriddenabstract valruns 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
usingof the namespace orusing staticof the extensions class (using static TestLibrary.Purrclock.TailExtensions;); a C# 14 extension property is not found through a namespace alias.A
suspendmember with a flow result awaits to the same wrapper (await PurrClock.WinderAsync()is a settableKotlinMutableStateFlow<int>, the top level included).A sync extension returning a
Flow,StateFloworSharedFlow(fun Loafer.ticks(count: Int): Flow<Int>) binds the same way asmylo.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.
A multi-bound
T(Picnic) works too: the argument is checked against every bound inside the coroutine, so a value that fails faults theTask.An inner class of a generic owner and a
suspendmember 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
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>>.
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:
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
IDisposablebeing disposed when the call returns; dispose it yourself.Threads. The lambda starts on the Kotlin dispatcher thread that invoked it. After its first
awaitthat does not complete synchronously it continues on the .NET thread pool, never on the caller'sSynchronizationContext, 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 outerTaskfromPlateAsyncfaults with the original message; catchExceptionrather than relying on the original type. A throw ofOperationCanceledException, or a cancelled returnedTask, 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 ownT, and a nullable of each, sosuspend (Char) -> Stringandsuspend (List<String>) -> Intbind. AThrowableresult binds asTask<Exception>(suspend (Int) -> ThrowableisFunc<int, CancellationToken, Task<Exception>>), unless it is declared narrower thanRuntimeException. A lambda of four or more parameters or aPairorIntArraypayload is refused by name, and the member is then not generated. A plain(T) -> Rlambda on asuspend funbinds separately; see A plain lambda on asuspendorFlowmember. Asuspendlambda parameter on a data classcopy, an enum member or a value class member is untested.Null. Passing
nullfor a non-nullable delegate throwsArgumentNullExceptionbefore Kotlin runs, as for any other lambda parameter. A nullable one ((suspend (T) -> R)?) binds asFunc<..>?and takesnull, which reaches Kotlin asnull; a nullable payload or result crosses asnulltoo.
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.
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:
Enumeration, disposal and errors
Cold.
GetAsyncEnumeratorruns once per Kotlincollect, 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
DisposeAsyncruns exactly once, however the collection ends: to the end, an early exit such astake(2), a throw, or cancellation. It runs on a queued work item, so it lands shortly after theTaskfrom the Kotlin call completes, not before. Do not assert or rely on disposal having happened at that instant. To observe an iterator'sfinally, signal from it and await the signal.Cancellation. When Kotlin cancels the collection (the
CancellationTokenyou 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
NugetManagedExceptionthat Kotlin can catch. If Kotlin does not catch it, the outerTaskfaults with the original message; catchExceptionrather than relying on the original type. An exception that Kotlin's own collector throws faults the outerTaskwith that exception, and the enumerator is still disposed.Threads. The synchronous part of
GetAsyncEnumeratorand of eachMoveNextAsync, up to its firstawaitthat does not complete synchronously, runs on the Kotlin dispatcher thread. What follows resumes on the .NET thread pool, never on the caller'sSynchronizationContext.Lifetime. Kotlin owns the enumerable after the call, as with a
suspendlambda 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
nullthrowsArgumentNullExceptionbefore 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>andMutableStateFlow<T>parameters. C# has no source for a synchronous current value or a replay cache. Declare the parameter asFlow<T>and callstateIn(scope)orshareInon it in Kotlin. A member that itself returns aStateFloworSharedFlowdoes not admit aFlowparameter either; its diagnostic still readsbut not <name>: Flow<Int>, which is about that host and not aboutFlowparameters in general.Other elements. Only the primitives,
Stringand 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 nullableFlow<T>?parameter binds asIAsyncEnumerable<T>?, and a C#nullreaches Kotlin asnull.Enum and value class members, and a data class
copy. A member of an enum or value class that takes aFlowis not generated, and a data classcopyomits aFlowcomponent.Implementing the interface in C#. A Kotlin interface member that takes a
Flowbinds for calling, but a C# class cannot implement that interface: the build reportsSKIPPED_UNIMPLEMENTABLE_INTERFACE, and passing such an implementation to Kotlin throwsNotSupportedException. A member that returns aFlowis 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:
The token your
FeedAsyncreceives is cancelled when the Kotlin coroutine is, for example bywithTimeoutOrNull. An exception it throws is catchable in Kotlin.KotlinFlow<T>.From(IAsyncEnumerable<T>)is cold: every Kotlincollectenumerates the source again, and a Kotlintake(2)disposes the enumeration early.For a
StateFlow,SharedFlowor mutable member, hand back a real Kotlin flow fromKotlinMutableStateFlow<T>.Create(initial)orKotlinMutableSharedFlow<T>.Create(replay). Kotlin reads and writes the same flow you hold, soflow.Value = 23in C# is seen bythermostat.level.valuein 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
suspendmember that returns aFlowisTask<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:
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:
suspendmember. Released when theTaskcompletes, faults or is cancelled. Cancellation before the coroutine starts counts. Cancel through theCancellationTokenas usual.Flowmember. Each enumeration registers the delegate afresh and releases it when that enumeration completes, faults, is cancelled or is abandoned withbreakorDisposeAsync(). Collecting twice calls the delegate during both.suspendmember returning aFlow. The delegate lives as long as the returnedKotlinFlow, 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 returnsnull, 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 { ... } }:
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:
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.
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:
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:
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:
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>
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#.
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.
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:
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:
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:
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>
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>:
EmitAsynccompletes once every subscriber has taken the value, or it is buffered or replayed. It suspends only while a subscriber is busy, and an optionalCancellationTokencancels it. An already-cancelled token returns a cancelledTaskwithout emitting. Disposing the owner cancels a parkedEmitAsyncon a property, so itsTaskends as cancelled instead of hanging.TryEmitnever 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
nullfor a non-null object element throwsArgumentNullException.SubscriptionCountreturns a newKotlinStateFlow<int>on every read. Dispose it; a dropped one is released by the GC.A method returning
MutableSharedFlow<T>, and asuspendfunction returning one, give you the flow itself. Keep it and dispose it, and every write lands in the same Kotlin flow:
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>
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:
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.
Valuestarts as the flow's current value and holds the last delivered element, so reading it costs nothing.PropertyChangedis posted toSynchronizationContext.Currentat theAsNotifying()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.Completionfinishes afterDispose()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.
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:
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:
CompareAndSet,Update,UpdateAndGetandGetAndUpdatecompare with Kotlinequalson the underlying value, never C# record-struct equality, so a freshly builtCatIdmatches the stored one and a stale one does not.A nullable element (
MutableStateFlow<CatId?>) is settable too andnullclears it.default(CatId)has noId, so writing it, or passing it toCompareAndSet, throwsArgumentExceptionin C# before anything crosses. This applies to a value class over aStringor an object.The underlying can be a
String, a primitive, aChar, an enum, a sealed type, an exported class or object, or (with its own box pair) anInstant,Duration,Uuid,ByteArray, collection, plain interface or another value class, soMutableStateFlow<PostPrint>reads and writes. A bareInstant,DurationorUuidelement is settable too, on its ticks or text wire. A value class over aThrowablehas 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, andnullstays distinct fromNick(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:
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):
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:
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:
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:
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:
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.Valueis the same Kotlin instance, so it matches; a secondCatwith the same fields does not. A data arm of a sealed type is equal by value, so a C#-builtnew Outcome.Ok<int>(1)matches theOk(1)Kotlin holds.A throwing Kotlin
equalssurfaces asKotlinInvalidOperationException, never asfalse.An absent
MutableStateFlow<T>?throws onCompareAndSet, 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.
Updatenever 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:
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:
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
.Valueread returns a new holder over the same Kotlin flow. Dispose every one; eachawait foreachstill 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-onlyKotlinStateFlow<T>and the build names the dropped setter. Handing a holder back into Kotlin (new Box<KotlinFlow<int>>(ticks)) throwsNotSupportedException.
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.
An element is the same kind of value
Tis anywhere else: a builtin, astringor a wrapper comes out as itself, and each wrapper element is yours to dispose.A
Flow<T>whoseTis unconstrained can emitnull, andFlow<T?>andBrook<string?>deliver it as a genuine item rather than ending the stream.A
Tyou 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 whenThas at most one bound. ATwith several bounds, orT : Enum<T>, binds read-only asKotlinStateFlow<T>.Cancelling an enumeration ends it cleanly, as on any other flow.
Not every shape binds:
A
varflow property is settable, as on any class.A
Flow<T>orsuspendlambda parameter that mentionsTbinds 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.
A builtin, a
string, a wrapper, a nullableTand an enum or multi-boundTall work, on a class, a generic class and a generic sealed arm. An explicitFlow<T?>under aT : Anybound drops thenullelements 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
suspendlambda 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 aList<T>is yours to dispose.The enumerator lifetime, cancellation and error rules of a
Flowparameter and asuspendlambda parameter apply unchanged.A
suspendmember that also takes a plain (non-suspend) lambda binds; see A plain lambda on asuspendorFlowmember.
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:
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:
A nullable List, Set or Map parameter binds as the nullable C# collection, and null reaches Kotlin as null, never an empty collection:
An enum parameter binds too, by ordinal ((int)x in C#, Q.entries[x] in Kotlin):
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:
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:
A class, object or sealed handle parameter may be nullable. It binds as T? and null reaches Kotlin as 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.
A value class's
initruns in Kotlin at the crossing. As a parameter it is boxed before the call, so a failinginitthrowsKotlinArgumentExceptionfrom the call itself, before anyTaskexists. As the receiver of a value class's ownsuspendmember it faults theTaskwithKotlinArgumentException.default(PostPrint)has no bytes. Passing it, writing it to a holder, or calling asuspendmember on it throwsArgumentExceptionin C#.A value class over a nullable
String(value class PostNote(val text: String?)) keeps an absentPostNote?(null) apart fromPostNote(null)(a struct whoseTextisnull).A
ByteArrayor collection argument is copied for the call. Mutating your array afterwards does not reach Kotlin.A
MutableStateFloworMutableSharedFlowof a bareByteArray, 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, andSubscriptionCountonMutableStateFlow<T>are not exposed (CompareAndSetandUpdateare; see Atomic updates).A
suspend funreturningMutableStateFlow<T>?, and a non-suspendfunction returningMutableStateFlow<T>?, bind a read-only holder. The build names each withSKIPPED_UNSUPPORTED_INPUT.A
StateFlow<T>,SharedFlow<T>orMutableStateFlow<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-
suspendfun f(): Flow<T>?and anobjector companionFlow<T>?member are skipped with a diagnostic, like their non-null forms.A
Pairas aFlow/StateFlowelement is not supported.Boolean?/Char?value elements on a nullableStateFloware not supported.suspend fun (): StateFlow<Throwable>and anyMutableStateFlow<Throwable>have no binding, for the same shared-export reason. A read-onlyStateFlow<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.