Value classes
A Kotlin value class wrapping a primitive or String becomes a C# readonly record struct around the underlying value: no handle, no IDisposable, structural equality for free. A value class wrapping another bridged class also becomes a record struct, but its one property is that class's own handle-backed type, so disposal still applies to the wrapped object.
init validation on a primitive- or String-underlying value class runs on construction; a failed require/check surfaces as the usual exception. On a value class wrapping another class, only a secondary constructor's validation crosses the bridge -- the primary constructor's init does not run when called from C#.
A value class over a String, a primitive, a Char, an enum, a nullable String, primitive or Char, an object handle, a sealed base or interface, Instant, Duration, Uuid, ByteArray, a collection (nullable too), another value class, or a plain interface binds (the last group is below). A value class over a Throwable, a nullable non-scalar other than a collection, or a type outside the export set is refused by name; every member typed with it, at any position, is a separate named skip (SKIPPED_UNSUPPORTED_TYPE: "its value class type X has no C# record struct, because it wraps kotlin.Throwable, which no value-class wire carries"). That covers a Box<X>, List<X>, lambda or Flow of one.
A Char underlying is a plain char property, and its init runs in the C# constructor like a String one. Char? surfaces as char?.
It works at every position a String underlying does: parameter, result, property, collection element and map key, Box<Monogram>, a lambda, a Flow element, a settable MutableStateFlow and an awaited result.
Over Instant, Duration, Uuid, ByteArray, a collection, another value class or an interface
A value class over one of these becomes a readonly record struct whose one property is the underlying's usual C# type, and it crosses on that type's own wire. Kotlin re-wraps it, so init runs when the struct reaches Kotlin, not when you construct it in C#.
Underlying | Struct property |
|---|---|
|
|
|
|
|
|
|
|
|
|
another value class | that value class's struct ( |
a plain interface | the generated |
These structs bind at parameters, results, properties and constructors, nullable included, exactly like the structs above, and as a List, Set or Map element, key or value. Unlike a bare ByteArray, a struct over a ByteArray or a collection is accepted as a Set element and a Map key, and compares by content (see below), so Contains or a lookup with a struct you just built matches. Their methods, properties and secondary constructors bind too. A failing init on the receiver throws the usual exception from the member call.
A
ByteArrayor collection is copied at each crossing, so changing the array you passed in does not reach Kotlin.A struct over a
ByteArrayor a collection compares by content, so it works as a dictionary key orHashSetelement. Abyte[]compares element by element, a list in order, a set ignoring order, a map by the same keys with equal values, and a nested struct through its own members. A struct over an interface keeps reference equality.defaultequals anotherdefaultand hashes to 0.
A collection underlying can be nullable.
value class TreatTin(val treats: List<Int>?)becomesTreatTin(IReadOnlyList<int>? Treats), andnulllist,TreatTin(null)andnulltin stay three different values under?, as aBox<TreatTin>and as an element.Optionals.TinSize(new TreatTin(null)); // -1, Kotlin sees a null list Optionals.TinSize(new TreatTin(new int[0])); // 0 TreatTin? empty = Optionals.MaybeTin(1); // not null: a tin with no listA value class over an interface holds an
IDisposableinterface value. Dispose a Kotlin-backed one you read out (using IRumbler rumbler = voice.Rumbler). A C#-implemented one you pass in comes back as the same instance.default(PawPrint), anddefaultof a struct over a collection or an interface, carries anullpayload that Kotlin could never have built. Crossing one throwsArgumentExceptionnaming the struct, as a parameter, nullable value, collection element or member receiver. A struct overInstant,DurationorUuidhas no null payload, so itsdefaultcrosses andinitdecides whether Kotlin accepts it.
A struct over any of these also crosses where C# sees only T or object, and on the suspend and Flow routes: new Box<PawPrint>(paw), a Flow<PawPrint> element, an awaited result and a write to a MutableStateFlow<PawPrint> all work, and new Box<PawPrint>(default) throws ArgumentException. Its own suspend and Flow members bind as instance methods on the struct (await paw.PadCountAsync()). See Instant, Duration, Uuid, ByteArray, interfaces and value classes.
A generic value class
A generic value class over its own type parameter, or a phantom one that never uses it, becomes a positional generic readonly record struct. Each closed instantiation crosses as its argument would on its own, so Sticker<Int> rides the primitive route and Sticker<Cat> the object route.
The struct is
Sticker<T>(T Value), with nowhereclause. A function's ownTinside it (Stick<T>,Peel<T>) binds like a bareT, and its bound becomes the method'swhere. Any bound works:T : Comparable<T>,T : Enum<T>and a multi-boundTall bind, on top-level functions and class members alike.fun <T : Comparable<T>> higher(a: Sticker<T>, b: Sticker<T>): Sticker<T> = if (a.value >= b.value) a else b fun <T : Enum<T>> napOf(sticker: Sticker<T>): Sticker<T> = stickerSticker<string> higher = Stickers.Higher(new Sticker<string>("Mylo"), new Sticker<string>("Oreo")); Sticker<Mood> napped = Stickers.NapOf(new Sticker<Mood>(Mood.Sleepy)); // where T : struct, EnumA
Comparable<T>bound is not spelled as a C# constraint, so Kotlin checks it.initdoes not run at the primary constructornew Sticker<int>(-1)in C#. It runs when the value first crosses into Kotlin, so the call that receives it throws. A non-generic value class validates at construction.default(Sticker<string>)passed to a non-nullSticker<String>parameter throwsArgumentExceptionin C#. For an unconstrainedT,defaultcarriesnull.A KDoc
[Sticker]link renders as plain<c>Sticker</c>, as it does for a generic class.A closed instantiation works as a
List,SetorMapcomponent and under?, and at an erased position. A function's ownTdoes too: see Under?and inside collections.A generic value class over a collection of its own
Tbinds too, see below.
Over a collection of its own T
A generic value class over a List<T>, Set<T>, Map<String, T>, List<T>? or List<T?> becomes a record struct over the matching read-only collection. It binds everywhere Sticker<T> does: a closed parameter or result, under ?, as a collection element, with a function's own T, as a class property or constructor parameter, its own members and secondary constructors, suspend and Flow members, and the erased positions. As with Sticker<T>, init runs in Kotlin when it crosses, and a default struct is refused before it crosses.
The struct compares by content, like any struct over a collection.
A
List<T>?underlying (MaybeBundle<T>) keepsnulllist apart from an empty one, andMaybeBundle<Int>?apart from both.The list is copied at each crossing, so changing the one you passed in does not reach Kotlin. An item that is an exported class crosses as its handle, so you dispose the objects you read out of
Itemslike any other.
When the underlying is a T?
A generic value class that uses its T as T? (Opt<T>(val value: T?), or Gappy<T> above) is spelled with a nullable type argument when the closed argument is a C# value type, because C# reads an unconstrained T? as plain T for a value type. A reference type and a function's own T keep their spelling.
Opt<Int> and Gappy<Int> therefore appear as Opt<int?> and Gappy<int?> in your signatures, and you construct them that way: new Opt<int?>(5).
Under ? and inside collections
A function's own T inside a generic value class binds under ? and as an element, key or value of a List, Set or Map, including MutableList and nested lists. An unconstrained T can be null, so Sticker<T>? keeps "no sticker" apart from Sticker(null).
C# cannot infer
TthroughNullable<>, so aSticker<T>?parameter called with aSticker<byte?>needs the explicitIsAbsent<byte?>(...). Without it the call fails withCS0411.initruns in Kotlin when the value crosses, soIsAbsent<int>(new Sticker<int>(-1))throwsArgumentExceptionbefore the call body runs. In a collection it runs per element.A
T : Anybound has no null of its own, soSticker<T>?there is a plainnull.A star-projected
Sticker<*>parameter becomes a method type parameter you pick at the call (Starred<TSticker>), on a class member too.
A star- or out-projected result
A Sticker<*> or Sticker<out Animal> result, nullable too, takes the same kind of type parameter, and you supply the one Kotlin actually returned.
A type argument that disagrees with what Kotlin returned fails when you read the value, as a wrong T does on any fun <T> f(): T. A star-projected result on an interface member is not bound.
Members, properties and constructors
The struct carries the class's own members, properties, secondary constructors and suspend members, on every instantiation including a phantom one.
A secondary constructor runs in Kotlin at
new Sticker<string>("ab", 2), soinitruns there. The primary constructor still does not.A member that throws, or whose receiver fails
init, throws in C# (KotlinArgumentExceptionfornew Sticker<int>(-1).Describe()). Asuspendmember faults itsTaskinstead.A member on
default(Badge<string>)over a non-nullTthrowsArgumentException("carries no Value; construct a Badge instead") before reaching Kotlin.A member's own
<R>binds on any value class, generic or not, and is renamed if it would clash with the struct'sT.A
Tyou pass or return is a bareT, so aCatyou read back fromSwap(...)is yours to dispose.
As an ordinary parameter, property, or return type
A value class isn't limited to being the receiver of its own methods: it also binds as a plain parameter, property, or return type anywhere else in the API, including constructors, copy(), extension functions, and top-level functions. The wire carries only the underlying value; Kotlin re-wraps it into the value class before the callee runs, so init validation applies on every parameter and every property write, not only at construction.
A value class wrapping an enum or another bridged class crosses the same way, riding that underlying's own wire (an int ordinal for an enum, the wrapped object's handle for a class), and is re-wrapped on the way back.
A default(ChartId) of a String - or class-wrapping value class carries no underlying value, which Kotlin could never have built. Passing one into a non-null parameter, property, or collection element throws ArgumentException in C#, naming the struct (default(ChartId) carries no Value; construct a ChartId instead). A ChartId? still accepts null.
Nullable
ChartId? binds as a genuine Nullable<ChartId>, not a reference nullable, everywhere a value class binds: property, parameter, return. null stays distinct from a value class wrapping an empty string, zero, or false.
A method or getter declared on the value class itself can return a nullable too: String?, Int?, an enum, an object, a List and Throwable? all bind, with null coming through as null. Object results are owned by you like any other returned object, so dispose them.
A value class's own members have no error slot, so an exception thrown inside one of them is not caught and rethrown in C# the way it is on other members. Do not let these members throw.
Primitive- and enum-underlying nullables
Dosage? and Temperament? (value classes wrapping a Double and an enum) behave the same as ChartId? above: a real Nullable<T>, with 0.0, a zero-ordinal enum case, and false all staying distinct from null. No extra code is needed on either side.
A bare Mood?, not wrapped in a value class, follows the same rule; see Enums: Nullable.
A value class whose underlying can be null
value class Nick(val name: String?) and value class TreatCount(val count: Int?) bind at every ordinary position. Nick? is a Nullable<Nick> where null and Nick(null) stay different values, at a parameter, result, constructor, property, extension receiver and as a collection element.
A non-null property typed
TreatCountbinds, and aMutableStateFlow<TreatCount>is settable.A
this Nick?extension needs aNick?receiver, so call it on a variable typedNick?.The closed
Sticker<String?>andSticker<String?>?behave the same way.
Over a sealed type
A value class can also wrap a sealed base, such as value class ObservationResult(val observation: Observation) over sealed class Observation. It binds at a property, a callable parameter or return, and as a List<T> element. C# reconstructs through the sealed base's own subtype, so it pattern-matches like any other sealed type.
As a collection component
A value class also binds as a List/Map/Set element, key, or value. The collection's wire never carries the value class itself, only its underlying value; C# and Kotlin re-wrap each element on the way in and out.
A nested collection of value classes (List<List<ChartId>>) is not supported; see Collections.
At an erased generic position
A value class also crosses at a generic class's T, and as a lambda payload or result, both positions where C# only sees T/object and Kotlin only sees Any?. C# hands across the boxed value class itself, not its underlying, so the round trip still validates through init and still compares structurally:
init runs at this boundary even for a value class wrapping another class, which otherwise never runs init from C# (see above): the box export is the first place it runs.
A value class over a sealed type crosses here too (Box<Clue>, a Flow<Dossier> element, an awaited result, a MutableStateFlow write), and so does one over a Char. A value class with no C# record struct (over a Throwable and the like) is a named skip at a lambda payload, a Flow element, an awaited result or a Box<V> member. Box<T> is an open C# generic with no build-time gate, so new Box<V>(v) for such a V still compiles and throws NotSupportedException.
A value class over a nullable underlying (value class Nick(val name: String?)) crosses here too. null and Nick(null) stay distinct: a Box<Nick?> or a Flow<Nick?> element holds either no Nick or a Nick with a null name.
A generic value class at an erased position
A generic value class crosses at every erased position for any instantiation: inside a generic class, as a lambda payload or result (nullable too), as a Flow, StateFlow, MutableStateFlow or SharedFlow element, as a Flow parameter, and as an awaited suspend result or parameter. A function's own T inside the struct works as well. Reads and writes both bind, so you can set a MutableStateFlow<Sticker<Int>> and pass your own Sticker<T> elements in a Flow parameter.
initruns in Kotlin when a value crosses in, sonew Box<Sticker<int>>(new Sticker<int>(-1))andcourier.Board.Value = new Sticker<int>(-1)throwArgumentException, and a C# lambda that returns such aStickerthrows inside the Kotlin call.A phantom
Decal<Cat>crosses the same way and keeps itsSerialas along.A Kotlin
Sticker<ulong>that your code has never constructed reads back fine: the generated member registers the instantiation before it returns.A generic value class's own
Flow,StateFlowandSharedFlowmembers and properties bind as on any value class, including an enum-boundT:new Sticker<string>("Mylo").Glow.Value,await foreach (string v in new Sticker<string>("Oreo").Trail()).
At an Any position
A value class crosses an Any position (Any) as its own struct. A generic one does too, and comes back closed over object? whatever it was constructed as:
init runs in Kotlin each time a C# value crosses in, and a failure is an ArgumentException.
The phantom Decal<T>(val serial: Long) crosses the same way and comes back as Decal<object?>. A generic value class whose underlying no erased position carries is the exception: it crosses an Any position as a KotlinObject you dispose, and passing that back hands Kotlin the same value.
As an extension receiver
A value class also works as the receiver of an extension function or extension property. The receiver crosses as its underlying value and is re-wrapped with the value class's own constructor before use, so init still runs. See Extensions for the general receiver mapping.
A member extension declared inside a value class binds as an instance method on the struct that takes the receiver first (paws.GetPawPrint(loafer), await paws.StretchAsync(loafer, 6)). See Member extensions.
Inherited members
A member whose signature comes from a supertype -- inherited, forwarded through interface delegation (value class ArticleUri(val value: String) : CharSequence by value), or explicitly overridden -- is never exported to C#. Only members with a signature no supertype declares are bridged. Reach the supertype's own API through the underlying property instead.
A member that merely shares a name with a supertype member, but not its parameter types, still exports as its own overload. Give a member a distinct name or signature if you need it bridged despite colliding with an inherited one.