Value classes
A Kotlin value class (inline class) wrapping a primitive or String becomes a C# readonly record struct around the underlying type: no handle, no IDisposable, no bridge allocation for the wrapper itself. A value class wrapping a reference type (another bridged class) also becomes a record struct, but its single property is the wrapped object's own handle-backed type.
Kotlin | C# | Notes |
|---|
value/inline class
| underlying type / record struct | see ADR-014 |
value class wrapping a reference type | record struct
| wraps the object's own handle type |
Kotlin
Wrapping a primitive-backed type (String), with a validating init and a secondary constructor, from test-library/src/nativeMain/kotlin/.../cat/CatId.kt:
value class CatId(val id: String) {
init {
require(id.length <= 20) { "Cat ID too long: $id" }
}
constructor(name: String, number: Int) : this("$name-$number")
val length: Int get() = id.length
fun isValid(): Boolean = id.isNotBlank()
}
Wrapping a reference type (Cat), from test-library/src/nativeMain/kotlin/.../cat/CatResult.kt:
value class CatResult(val cat: Cat) {
val name: String get() = cat.name
fun isAlive(): Boolean = cat.lives > 0
}
Generated C#
CatId becomes a readonly record struct whose primary constructor routes through Kotlin (so the init validation still runs), via a private CreateChecked helper:
public readonly record struct CatId
{
public string Id { get; }
[DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "catid_create")]
private static extern IntPtr Native_Create(string id, out IntPtr error);
private static IntPtr CreateChecked(string id)
{
IntPtr underlying = Native_Create(id, out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
return underlying;
}
public CatId(string id)
{
Id = Marshal.PtrToStringUTF8(CreateChecked(id))!;
}
// Secondary constructor (name, number) follows the same CreateChecked_2 pattern
public int Length => Native_GetLength(Id);
public bool IsValid() => Native_IsValid(Id);
}
CatResult, wrapping the reference type Cat, is a record struct over Cat rather than a primitive:
public readonly record struct CatResult(Cat Cat)
{
[DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "catresult_get_name")]
private static extern IntPtr Native_GetName(IntPtr value);
public string Name => Marshal.PtrToStringUTF8(Native_GetName(Cat._handle))!;
[DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "catresult_isAlive")]
[return: MarshalAs(UnmanagedType.I1)]
private static extern bool Native_IsAlive(IntPtr value);
public bool IsAlive() => Native_IsAlive(Cat._handle);
}
Because record struct gives structural equality for free, CatId/CatResult don't need generated Equals/GetHashCode overrides the way data classes do. C# derives them from the wrapped property automatically.
Methods with parameters
Value-class methods (both primitive-underlying and reference-underlying) go through the shared callable plan (ADR-062), including parameters. From test-library/.../clinic/ClinicSample.kt (ADR-060 cells 15 and 16):
value class ChartId(val value: String) {
fun matches(other: String): Boolean = value == other
fun isValid(): Boolean = value.isNotBlank()
}
value class ChartRef(val patient: Patient) {
fun label(suffix: String): String = "${patient.name}$suffix"
}
Generated C#, from Interop.cs:
public readonly record struct ChartId
{
public string Value { get; }
public ChartId(string value)
{
Value = Marshal.PtrToStringUTF8(CreateChecked(value))!;
}
public bool Matches(string other) => Native_Matches(Value, other);
public bool IsValid() => Native_IsValid(Value);
}
public readonly record struct ChartRef(Patient Patient)
{
public string Label(string suffix) => Marshal.PtrToStringUTF8(Native_Label(Patient._handle, suffix))!;
}
From IntegrationTests/ValueClassTests.cs:
[Fact]
public void ChartId_Matches_SameValue_ReturnsTrue()
{
var id = new ChartId("abc");
Assert.True(id.Matches("abc"));
}
[Fact]
public void ChartRef_Label_AppendsSuffix()
{
using var patient = new Patient("Rex");
var chart = new ChartRef(patient);
Assert.Equal("Rex-ward", chart.Label("-ward"));
}
Using it from C#
Primitive-backed value class, from IntegrationTests/ValueClassTests.cs:
[Fact]
public void CatId_Constructor_WithMultipleValues_WrapsString()
{
var id = new CatId("oreo", 123);
Assert.Equal("oreo-123", id.Id);
}
[Fact]
public void CatId_Equality_SameValue_AreEqual()
{
var oreoId1 = new CatId("oreo-123");
var oreoId2 = new CatId("oreo-123");
Assert.Equal(oreoId1, oreoId2);
}
[Fact]
public void CatId_Length_ReturnsUnderlyingStringLength()
{
var id = new CatId("oreo-123");
Assert.Equal(8, id.Length);
}
Reference-backed value class, from IntegrationTests/ReferenceValueClassTests.cs:
[Fact]
public void CatResult_Constructor_WrapsClass()
{
using var oreo = new Cat("Oreo", 9);
var result = new CatResult(oreo);
Assert.Equal("Oreo", result.Cat.Name);
}
[Fact]
public void CatResult_Equality_SameUnderlying_AreEqual()
{
using var oreo = new Cat("Oreo", 9);
var result1 = new CatResult(oreo);
var result2 = new CatResult(oreo);
Assert.Equal(result1, result2);
}
Constructor validation propagating an exception through CreateChecked, from IntegrationTests/ValueClassConstructorExceptionTests.cs (see Exceptions for the full picture):
[Fact]
public void CatId_PrimaryConstructor_TooLong_ThrowsArgumentException()
{
// 21 characters — exceeds the init block's `id.length <= 20` requirement.
Assert.ThrowsAny<ArgumentException>(
() => new CatId("supercalifragilisticx"));
}
As an ordinary return type
A String-underlying value class also binds correctly at an ordinary position, not only as the receiver of its own methods: a plain class-method return typed as the value class. From test-library/src/nativeMain/kotlin/.../Newsroom.kt (a value class declared one Gradle module away, in :test-models; see The nuget {} DSL for the cross-module export closure):
value class StoryCode(val value: String)
class Newsroom {
fun code(): StoryCode = StoryCode("BREAKING-001")
}
Generated C#, from Interop.cs:
public readonly record struct StoryCode
{
public string Value { get; }
// ...
}
public class Newsroom : IDisposable, IAsyncDisposable
{
// ...
public global::TestLibrary.Models.StoryCode Code()
{
IntPtr nativeResult = Native_Code(_handle, out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
return new global::TestLibrary.Models.StoryCode(Marshal.PtrToStringUTF8(nativeResult)!);
}
}
From IntegrationTests/NewsroomReachabilityTests.cs:
[Fact]
public void Code_CrossModuleValueClass_BindsAsUnwrappedValue()
{
using var newsroom = new Newsroom();
var code = newsroom.Code();
Assert.Equal("BREAKING-001", code.Value);
}
StoryCode binds as the unwrapped record struct, never as an IDisposable handle, which is worth calling out because it is easy to get wrong here specifically: KSP reports a cross-module value class with Modifier.INLINE, never Modifier.VALUE, so a classification check written for Modifier.VALUE alone would silently misclassify it as an ordinary class.
Limitations
A value class at an ordinary position (a plain method/property return or parameter typed as the value class, rather than the value class's own receiver) is currently scoped to a String underlying type only, the shape shown above. Primitive-, Enum-, and object-underlying value classes at an ordinary position, and value classes at ordinary parameter positions, are not yet planned (see ROADMAP.md).
Reference-underlying value-class primary constructor init validation stays deferred (ADR-035); primitive-underlying validation (the CatId path above) is in place.
Whether inherited members on a value class (for example CharSequence members via by value) should be exported is still an open product decision; declared methods with parameters are planned. In the meantime they are excluded from the generated C# API with a SKIPPED_INHERITED_MEMBER diagnostic naming each one, rather than binding silently (ADR-064).
Last modified: 21 July 2026