Generics
Generic classes and functions cross the bridge through a type-erased native layer plus a typed C# surface. The Kotlin side exports one bridge entry point per primitive type argument (create_string, create_int, ...) plus a generic create_object for reference types; NugetMarshal on the C# side dispatches to the right one at runtime based on typeof(T). Type constraints and variance both carry through to the generated C# generic parameter.
Kotlin | C# | Notes |
|---|
class<T>
| class<T>
| type-erased bridge + generic C# wrapper |
class<T>(...) constructor
| typed constructors | typed arguments through the bridge |
fun <T> f()
| typed variants | runtime dispatch via NugetMarshal |
<T : Bound> constraint
| where T : ...
| see ADR-015 |
out T/in T variance
| out T/in T
| see ADR-016 |
inline fun
| regular method | see ADR-017 |
inline fun <reified T>
| typed variants | reified type parameters |
typealias
| C# alias / underlying | generic type aliases, see ADR-018 |
Kotlin
An unconstrained generic class, from test-library/src/nativeMain/kotlin/.../cat/Box.kt:
class Box<T>(val value: T) {
init {
require(value.toString().isNotEmpty()) { "Box cannot hold a blank value" }
}
}
A constrained generic class, from test-library/src/nativeMain/kotlin/.../cat/PetBox.kt:
class PetBox<T : Pet>(val value: T) {
init {
require(value.name.isNotBlank()) { "PetBox needs a named pet" }
}
}
Generic functions, from test-library/src/nativeMain/kotlin/.../cat/Helpers.kt:
fun <T> identity(value: T): T = value
fun <T> wrapInBox(value: T): Box<T> = Box(value)
fun <T : Pet> adoptPet(pet: T): T = pet
inline fun <reified T : Pet> groomPet(pet: T): T = pet
Variance, from test-library/src/nativeMain/kotlin/.../cat/Variance.kt:
interface Readable<out T> {
fun read(): T
}
interface Writable<in T> {
fun write(value: T)
}
inline fun (non-reified), from test-library/src/nativeMain/kotlin/.../math/Arithmetic.kt:
inline fun square(x: Int): Int = x * x
Generic type aliases, from test-library/src/nativeMain/kotlin/.../TypeAliases.kt:
typealias Score = Int
typealias CatNames = List<String>
typealias CatScores = Map<String, Int>
fun topScore(): Score = 10
fun defaultNames(): CatNames = listOf("Oreo", "Mylo")
fun defaultScores(): CatScores = mapOf("Oreo" to 10, "Mylo" to 8)
Generated C#
Box<T> uses NugetMarshal.CreateBox<T> to dispatch construction by runtime type:
public class Box<T> : IDisposable
{
internal IntPtr _handle;
public Box(T value)
{
_handle = NugetMarshal.CreateBox<T>(value);
}
public T Value => NugetMarshal.FromHandle<T>(BoxNative.Get_value(_handle));
public void Dispose() { /* ... */ }
}
PetBox<T> carries the constraint through to C#'s where clause, and reflects out the _handle field of the constrained argument to pass across the bridge:
public class PetBox<T> : IDisposable where T : IPet
{
internal IntPtr _handle;
public PetBox(T value)
{
var field = typeof(T).GetField("_handle",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public);
IntPtr handle = PetBoxNative.Create_object((IntPtr)field!.GetValue(value)!, out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
_handle = handle;
}
public T Value => NugetMarshal.FromHandle<T>(PetBoxNative.Get_value(_handle));
public void Dispose() { /* ... */ }
}
A generic function dispatches per primitive type at runtime, falling back to the object/handle path otherwise:
public static T identity<T>(T value)
{
if (typeof(T) == typeof(string))
return (T)(object)Marshal.PtrToStringUTF8(identity_string_native((string)(object)value!))!;
if (typeof(T) == typeof(int))
return (T)(object)identity_int_native((int)(object)value!);
// ... long, float, double, bool ...
var field = typeof(T).GetField("_handle", /* ... */);
IntPtr handle = (IntPtr)field!.GetValue(value)!;
IntPtr result = identity_object_native(handle);
return (T)Activator.CreateInstance(typeof(T), /* ... */, new object[] { result }, null)!;
}
A constrained generic function carries the where clause the same way as the class:
public static T adoptPet<T>(T pet) where T : IPet
{
var field = typeof(T).GetField("_handle", /* ... */);
IntPtr handle = (IntPtr)field!.GetValue(pet)!;
IntPtr result = adoptPet_object_native(handle);
return (T)Activator.CreateInstance(typeof(T), /* ... */, new object[] { result }, null)!;
}
inline fun <reified T : Pet> groomPet generates identically to a regular constrained generic function (groomPet<T>(T pet) where T : IPet). Reification only matters Kotlin-side, where it lets the function inspect T at the call site; the bridge doesn't need to know the difference.
Variance carries straight through to the C# interface declaration:
public interface IReadable<out T> : IDisposable
{
T Read();
}
public interface IWritable<in T> : IDisposable
{
void Write(T value);
}
Generic type aliases erase to their underlying type; Score (an alias for Int) generates identically to Int, and CatNames/CatScores (aliases for List<String>/Map<String, Int>) generate as IReadOnlyList<string>/IReadOnlyDictionary<string, int>. There's no separate alias type in the generated C#.
Using it from C#
Unconstrained generics, from IntegrationTests/GenericTests.cs:
[Fact]
public void Box_Cat_ConstructorAndGetter()
{
using var oreo = new Cat("Oreo", 9);
using var box = new Box<Cat>(oreo);
using Cat cat = box.Value;
Assert.Equal("Oreo", cat.Name);
}
Constrained generics, from IntegrationTests/GenericConstraintTests.cs:
[Fact]
public void PetBox_TypeParameter_HasIPetConstraint()
{
Type[] constraints = typeof(PetBox<>).GetGenericArguments()[0].GetGenericParameterConstraints();
Assert.Contains(typeof(IPet), constraints);
}
[Fact]
public void AdoptPet_Oreo_ReturnsSameCat()
{
using var oreo = new Cat("Oreo", 9);
using Cat adopted = Helpers.adoptPet<Cat>(oreo);
Assert.Equal("Oreo", adopted.Name);
}
Generic functions, from IntegrationTests/GenericFunctionTests.cs:
[Fact]
public void WrapInBox_Int()
{
using Box<int> box = Helpers.wrapInBox<int>(99);
Assert.Equal(99, box.Value);
}
Variance, from IntegrationTests/VarianceTests.cs:
[Fact]
public void IReadable_Covariance_AllowsNarrowingAssignment()
{
// IReadable<Cat> can be assigned to IReadable<IPet> because T is covariant (out T)
Assert.True(typeof(IReadable<IPet>).IsAssignableFrom(typeof(IReadable<Cat>)));
}
[Fact]
public void IWritable_Contravariance_AllowsWideningAssignment()
{
Assert.True(typeof(IWritable<Cat>).IsAssignableFrom(typeof(IWritable<IPet>)));
}
inline fun square, from IntegrationTests/ArithmeticTests.cs:
[Fact]
public void Square_ReturnsSquaredValue()
{
int result = Arithmetic.square(5);
Assert.Equal(25, result);
}
Type aliases, from IntegrationTests/TypeAliasTests.cs:
[Fact]
public void TopScore_ReturnsInt()
{
int result = TypeAliases.topScore();
Assert.Equal(10, result);
}
[Fact]
public void DefaultScores_ReturnsReadOnlyDictionaryOfStringInt()
{
IReadOnlyDictionary<string, int> scores = TypeAliases.defaultScores();
Assert.Equal(2, scores.Count);
}
Limitations
Variance (out/in) declared on a generic class's own type parameter (as opposed to an interface's, shown above) is dropped: C# does not support variance on classes. The member still binds; the generator notes it with an INFO_DROPPED_VARIANCE diagnostic rather than silently ignoring the annotation (ADR-064).
A generic class declared in a dependency module and admitted through the export reachability closure resolves fully but still routes to the legacy generic protocol, which has never been exercised across a module boundary; it is skipped with the existing diagnostic rather than generated (ADR-066).
Last modified: 21 July 2026