Generics
Generic Kotlin classes and functions become generic C# classes and methods, keeping the same type parameter, constraints, and variance.
An exported enum class works as the type argument too, nullable included (new Box<Mood>(Mood.Calm), Box<Mood?>, Helpers.Identity<Mood?>(null)), on a generic class and on a generic method. Kotlin receives the real enum entry, not its ordinal. A value outside the enum's range ((Mood)99) throws a catchable KotlinException at the call.
Type mappings
Kotlin | C# |
|---|---|
|
|
| a generic |
|
|
|
|
|
|
| a generic method |
|
|
|
|
| an ordinary method |
| an ordinary generic method |
| the aliased type itself, no separate alias type |
Nullable properties
A nullable property on a generic class (val x: T?) is T? in C#. At a reference-type instantiation a null read stays null; at a value-type instantiation it collapses to default(T), same as any other unconstrained C# generic. A property that doesn't mention T keeps its own declared type instead, at every instantiation:
Nullable type arguments
An unconstrained T (no bound, so its implicit upper bound is Any?) accepts a null argument at a constructor, method parameter, or return, and reads back null, the same way any other nullable handle position does:
A bound rules this out. class Tin<T : Any>(val value: T) renders where T : notnull, so a nullable type argument is a compile-time warning (CS8714) in a nullable-enabled consumer, on top of the runtime failure a null argument would still hit on the Kotlin side. A nullable bound (T : Pet?) keeps its ? and behaves like the unconstrained case.
The same rule applies to a top-level generic function: Helpers.Identity<int?>(null) and fun <T : Any> handBack(treat: T): T behave the same way as their generic-class counterparts.
If a generic class's property name collides with one of its own type parameter names once both are PascalCased (class Duo<A, B>(val a: A, val b: B), where property A and type parameter A would both render A), the type parameter is renamed in C# (A becomes TA, B becomes TB); the property keeps its ordinary name. A diagnostic about the type parameter still names it the way you wrote it in Kotlin.
Constraints
A bound (<T : Pet>) becomes a C# where T : ... clause. Any type assignable to the bound works as the type argument:
A bound declared in the Kotlin standard library (Comparable<T>, Number, CharSequence) has no C# equivalent, so it is dropped from the where clause and the build reports an INFO_DROPPED_BOUND note. A non-null bound keeps where T : notnull. C# can then pass a type argument Kotlin would reject, which Kotlin refuses when it reads the argument, before the function body or constructor runs. The call throws KotlinInvalidCastException.
A parameter with several bounds lists every exportable one in the where clause, on a class and on a function alike. The stdlib bounds are dropped as above, so where T : Comparable<T>, T : Pet renders where T : IPet.
A bound on a generic interface keeps its type arguments in the where clause, so class Chain<T> where T : Node<T>, T : Pet over an invariant Node<T> is where T : INode<T>, IPet. A bound C# cannot spell (a use-site projection, or a collection argument) is dropped with INFO_DROPPED_BOUND, and a T outside it throws at the call.
An enum bound
T : Enum<T> binds as where T : struct, global::System.Enum, on a generic class, a generic function and a member's own type parameter. Any exported enum is a valid type argument, and Kotlin runs the enum API (name, ordinal, compareTo) on the real entry:
A T with an enum or self-referencing bound also works as a collection element, a map key, and inside a generic class, on a function, a class member or a constructor. T is inferred as usual:
Rosette<int>,Rosette<string>, a wrapper andRosette<Medal?>do not compile.C# cannot say "an enum generated from Kotlin", so a .NET enum such as
DayOfWeekcompiles and throws a catchableNotSupportedExceptionat the call, before anything reaches Kotlin.A
Tproperty is settable, as on any generic class.inline fun <reified T : Enum<T>>is skipped with a namedSKIPPED_UNSUPPORTED_COMBINATION, because the generated export cannot supply a reified argument. Dropreified, or export a non-generic overload per enum. AsuspendorFlowmember of an enum-bound class is skipped as on any generic class.A nullable bound (
T : Enum<T>?) is dropped from thewhereclause withINFO_DROPPED_BOUND.T : Pet, T : Enum<T>binds, but no C# enum implementsIPet, so nothing satisfies it.
A generic bound from another package
When the bound is declared in a different Kotlin package than the generic class itself, the generated where clause spells it fully qualified (where T : global::TestLibrary.Cat.IPet) instead of a bare name. This only matters if you inspect the constraint through reflection; calling the class works the same either way.
Variance
out T/in T on an interface carries straight through to the C# interface:
Variance declared on a class's own type parameter, as opposed to an interface's, is dropped: C# does not support variance on classes. The class still generates and works, just without out/in on its type parameter.
Writable properties
A var typed by the class's own type parameter gets a C# setter: T, T?, and a List, Set or Map of T. Value types and strings are boxed for the call and the box is released afterwards; an exported class you pass in is borrowed, so it stays yours to dispose.
The same holds for a bounded T (T : Furry), a trampolined T : Enum<T>, and an interface var item: T, which declares T Item { get; set; } on IKeepsake<T>. Playpen<int>.Maybe = null does not compile, because an unconstrained T? is spelled T in C# (see Nullable properties). A generic interface binds at every position, and one you implement in C# crosses to Kotlin and comes back as itself; see A generic interface.
Methods on a generic class
A public method declared on a generic class binds as an instance method on the C# generic carrier, T positions included, the same ADR-062 plan an ordinary class's methods bind on:
A position that never mentions T (Label above) binds exactly as it would on a non-generic class. A position that does (Describe, Pick) crosses as the same boxed handle a T-typed property already uses: a value type pays one box mint and dispose per call, an exported class instantiation borrows the argument's own live handle and mints nothing on the way in, and a value class mints and disposes a box through its own box/unbox pair, running init at the boundary. A generic one (Box<Sticker<Int>>, or Sticker<T> over a function's own T) uses a single pair for every instantiation, see A generic value class at an erased position. A value class over a sealed type or a Char has a pair too, and so does one over Instant, Duration, Uuid, ByteArray, a collection, a plain interface or another value class, so Box<PawPrint> crosses like Box<CatId>. A Kotlin member that uses a value class with no C# record struct (over a Throwable, and so on) at T is skipped by name, and new Box<V>(v) for such a V written in C# throws NotSupportedException at the call, since an open C# generic has no build-time way to refuse it.
An interface at an erased position
T bound to an exported interface (PetBox<T : Pet>, or the unconstrained T above) accepts your own C# implementation and hands back the SAME instance, not a fresh Kotlin-backed wrapper. A Kotlin-backed value read at T = IPet still materializes as IPet, through a factory the interface itself now carries:
Each crossing mints a bridge handle for your C# object (freed after the call) or reads one back by token (freed by the read); see Implementing a Kotlin interface in C#. T bound to something that isn't your own C# type and also isn't a Kotlin-backed instance Kotlin can reconstruct (a different implementation entirely) throws InvalidCastException; a T = Dog read of a Kotlin-backed value that isn't a Dog throws NotSupportedException naming the missing factory, inherent to erasure. The same identity rule applies to a lambda argument (see Lambdas and callbacks) and to generic functions below.
T is admitted at a parameter, a return, a constructor parameter, or a property (getter and setter), optionally nullable, and as the element of a List, Set or Map at those positions (List<T> binds as IReadOnlyList<T>; see A type parameter as an element). It is refused, named, everywhere else: T nested inside another generic type. A T in a lambda (non-suspend or suspend) and in a Flow parameter binds. A type parameter declared on an interface rather than a class is unaffected by this and keeps its own, unrelated named refusal.
Subclassing a generic base
A class extending an exported generic base spells the closed type argument, and inherits members, including methods, from that closed base:
Because Parcel and Crate are declared open, their generated Dispose() is virtual so a subclass can override it. A subclass declaring its own overload of a name it also inherits from the generic base keeps exactly its own declared member; the base's substituted overload is not re-declared alongside it, and C# overload resolution then picks between the two exactly as Kotlin does. Generic subclasses (class Sub<T> : Base<T>(...)) are not supported yet; declare the member directly on the closed subclass instead.
An unexported class between the subclass and the generic base (even a generic Keel<U> : Crate<U>) changes nothing for the consumer: the exported subclass extends the generic base directly and declares only what it or the unexported class declares.
Crate<T> has LoadAsync too (a generic class binds its suspend members), and Barge inherits it; the override runs on the Kotlin side.
A generic class at a member position
A closed instantiation of an exported generic class (Box<String>, Box<Cat>, Box<Box<Int>>, Box<String>?) binds anywhere a class does: a property (a var too), a method return or parameter, a constructor parameter, a companion, an object, the top level, an extension receiver, a List element, a suspend result or parameter, and a Flow or StateFlow element.
A Box<T> Kotlin hands back is yours to dispose, and every read is a new wrapper over the same Kotlin object. A Box<T> you pass in is borrowed: the call neither keeps nor disposes your wrapper. The type argument can be a primitive, String, an exported class, enum, interface or value class, a nullable of those (Box<String?>), or another closed instantiation.
A top-level function that returns an instantiated generic class works the same way, and can take enum, primitive, String and exported class parameters. A nullable one keeps its null, and overloads bind side by side:
A generic abstract class returns the same way. The value is an internal subclass that forwards to the Kotlin object, as described in An abstract class as a return type.
These are skipped, named, instead of generating C# that fails to compile:
A type argument the bridge cannot read back: a collection (
Box<List<Int>>),ByteArray, a lambda, anobjectorUnit. AFloworStateFlowargument binds; see A flow as a type argument.A use-site
outprojection (Box<out Cat>), since C# has none for a generic class. A star projection binds:Box<*>isBox<object?>, read at the parameter's bound (see Any). A star-projected generic value class result is a type parameter instead; see A star-projected result. So is a star-projected extension receiver; see Projection receivers.An inner class of a generic owner (
Tin<Int>.Latch). A generic interface (Shelf<String>) binds at every position; see A generic interface.An outer type that is not an exported class (
Pair<Int, Int>).
A refused argument is reported as SKIPPED_UNSUPPORTED_TYPE naming the member, for example for a Box<List<Int>> return: "its generic class <package>.Box has no C# spelling here: the erased wire cannot read its type argument List<Int> ".
A MutableStateFlow<Box<Int>> binds as a KotlinMutableStateFlow<Box<int>> with a settable .Value, CompareAndSet and Update (see Settable .Value); the written box is borrowed, so you still dispose it. A C# class can implement a Kotlin interface that has a member returning Box<Int> or taking one.
A generic sealed hierarchy binds at the same positions; see A generic sealed hierarchy. Unlike a generic class, a generic sealed arm also binds its callback members; see Members of a generic arm.
A flow as a type argument
Box<Flow<Int>> and Box<StateFlow<Int>> bind as Box<KotlinFlow<int>> and Box<KotlinStateFlow<int>>, including as a top-level function return. box.Value is a holder you await foreach and dispose, and every read of .Value returns a new one. The holder is read-only, and Box<MutableStateFlow<T>> binds as a read-only KotlinStateFlow<T>. See A flow inside a generic class for the example, disposal rules and what is skipped.
Generic functions
A constrained generic function carries the same where clause as a constrained class. Inlining only matters inside Kotlin. A reified T reifies at its declared bound (Pet for groomPet), and at Any? when it has none, whatever type argument C# passes.
An unconstrained T bound to an exported interface gets the same identity treatment as the generic-class case above: the object arm now writes through the same shared marshalling helper every other erased route uses, so it also picks up boxed value classes for free.
Every top-level fun <T>, whatever its parameters and return, binds like a generic method on a class: a static generic method on the file's class, with every T position boxed. A builtin T (Identity<int>(4)) therefore crosses as a box, not as a single typed call. A parameter named handle keeps that name on a top-level fun <T>.
A top-level fun <T> with a suspend lambda or Flow in the signature (a Flow return has no top-level route) or a reified T whose bound C# cannot spell is not generated, and the warning names which one. A suspend fun <T> binds as a Task<T>; see A suspend fun with its own type parameter.
A C# builtin (int, double, string, short, ...) works as T, with a stdlib bound or none. A bound C# cannot name is dropped, so a type argument outside it compiles and throws KotlinInvalidCastException at the call:
Generic methods on a class
A member function's own type parameter becomes a C# generic method, with the constraint and with inference at the call site. This works on a class, a companion, an object, a generic class, an abstract class, a sealed base and a sealed arm:
A suspend member binds as a generic Task<T> method on a class or sealed arm, generic owner included; see A suspend fun with its own type parameter and suspend members of a generic class.
What differs from a generic class:
Every
Tcrosses as a boxed handle, so a builtin type argument (int,string,double) works and there is nothing for you to dispose. A wrapper type argument comes back as the same Kotlin object, and you dispose it as usual.A bound is a
whereclause (T : Anyiswhere T : notnull), and several bounds each list. A type argument outside a bound C# cannot name throwsKotlinInvalidCastExceptionbefore the body runs. AnEnum<T>bound iswhere T : struct, global::System.Enum(An enum bound).A
Result<T>return unwraps toTand gains itsTrytwin, as on any other member.An
overriderepeats nowhereclause, which C# forbids, and an override that spellsT?sayswhere T : default. Write your own C# override the same way.A type parameter named like its member or its owner (
fun <Hold> hold) is renamed with aTprefix (THold).
These stay a named skip, with the specific reason in the warning: a generic method on an interface, an enum class, or a value class (an extension fun <T> X.f(), sync or suspend, binds, see Generic and bare T receivers); a reified type parameter on a member (a top-level fun <T> binds one); a bound that is itself a type parameter; a T nested in a type that is not an exported generic class or generic value class (List, Set, Map and Result aside); a multi-bound T that no parameter mentions; and a T that shadows a type parameter in scope (the owner's, or one an inner class captures). A generic method on an inner class or a type nested in a generic owner binds like any other. Expose a non-generic wrapper for any of the refused shapes. The warning ends its reason with the shape it refused:
A type parameter as an element
A method's own T, or a generic class's T, can be the element of a List, Set or Map, in a parameter, a return or a getter. It binds on a class, object, companion, generic class member and top-level function. T is inferred from the argument.
A passed wrapper element is borrowed, and each wrapper element Kotlin returns is yours to dispose. List<List<T>> works, and a bounded T keeps its where clause. A Map key must be non-null: fun <K : Any, V> tally(map: Map<K, V>) binds, while an unconstrained K is skipped as NULLABLE_MAP_KEY. Declare K : Any. A T with an enum or self-referencing bound is a valid element (An enum bound), and so is one with several bounds that no bare parameter mentions:
A T in a lambda binds; see below.
A type parameter in a lambda
A method's own T, or a generic class's T, can be a payload or the result of a non-suspend lambda parameter. T is inferred from the arguments, and the lambda is an ordinary Action<T> or Func<T, ...>.
A wrapper payload follows the usual callback ownership: the lambda owns it and disposes it. A wrapper the lambda returns, whether the payload, one you already hold or a fresh one, needs no extra step: Kotlin takes its own reference, so you still dispose any wrapper you hold. A T? payload, or a T that is null, arrives as null. A bound with no closed spelling (T : Enum<T>) works in both directions.
A suspend lambda or a Flow parameter over T binds too. A stored callback or listener pair over T binds too, on a generic class and on a generic sealed arm (addShake(listener: (T) -> Unit) is IDisposable AddShake(Action<T> listener) on Shaker<T>; see Stored callbacks). A generic sealed arm's lambda members bind like the class's.
A type parameter inside a generic class or a Result
A method's own T can be an argument of an exported generic class (Basket<T>, Basket<T>?, Basket<Basket<T>>, List<Basket<T>>, a generic sealed Pickup<T>), and the payload of a Result return (Result<List<T>>, Result<Set<T>>, Result<Map<K, V>> with K : Any, Result<Basket<T>>). It binds on the same owners as a bare T, and T is inferred from the argument. A multi-bound T works here too: fun <T> crown(basket: Basket<T>): T where T : Furry, T : Tricky is T Crown<T>(Basket<T> basket) where T : IFurry, ITricky.
A returned basket is yours to dispose, and a passed one is borrowed. A Result payload gains the Try twin of any other Result return. A Basket<T> also works inside a class-T member (Basket<Basket<T>>) and reads its inner basket without the consumer touching Basket<int> first. The same holds at any depth and any position: a getter or setter, a parameter, a list or map element, a callback argument, a Flow element, a returned lambda, a suspend result or a Result payload.
What still skips, named:
kotlin.Array<T>.A nested generic class argument the wire cannot read, such as
Box<List<T>>.
Type aliases
A typealias erases to its underlying type; there is no separate alias type in the generated C#. This holds on every route, including a use-site ? on the alias itself and a suspend or Flow member, so an aliased type behaves exactly like the written-out type would, refusals included:
A generic alias substitutes its type parameter only when the parameter is one of the RHS's own top-level type arguments, as Box<T> above does. A parameter nested deeper in the RHS (typealias Pages<T> = List<Map<String, T>>) is not substituted; a member using it is skipped with a named warning instead of generating a wrong binding. Write the type out at that position if you need it exported.
Erasure applies to an extension's receiver too, including a nested-type alias (see Extensions: Nested receivers).
Limitations
A generic class declared in a dependency module is exported like a module-local one once the export closure reaches it, with Int and String type arguments checked. Other type arguments on a dependency generic class are not checked; if one fails, declare the class in the publishing module itself.
A T? written at a generic function's return (fun <T> f(item: T): Crate<T?>) is spelled T in C#, so the type argument you choose decides whether null can be held.
A generic class binds its ordinary members, including a member that takes a lambda (box.Measure(n => n * 10)). Its Flow, StateFlow and MutableStateFlow members bind too, as KotlinFlow<T> and its siblings (see A Flow with a type parameter), and so do its suspend members (see suspend members of a generic class), and its lambda members, stored callback pairs and interface listener pairs, a Char payload included.
A type nested inside a generic class is declared on a non-generic static class beside it: Box<T> { class Lid } gives Box<int> and a separate Box.Lid, never Box<int>.Lid. See Classes and objects: Nested types and generic classes. A member that returns an inner class of a generic owner is skipped with a named diagnostic, but the generic owner is still exported.