Primitives and strings
Primitive types follow the standard Kotlin/Native C interop mappings. Strings marshal as UTF-8. Nullable primitives and nullable strings use a two-call pattern since a C ABI value type can't itself carry "no value".
Kotlin | C# | Notes |
|---|
Byte/Short/Int/Long
| sbyte/short/int/long
| |
UByte/UShort/UInt/ULong
| byte/ushort/uint/ulong
| |
Float/Double
| float/double
| |
Boolean
| bool
| |
Char
| char
| 2-byte scalar (ushort at the C ABI); property, parameter, and method return, see ADR-062 |
String
| string
| UTF-8 marshalling |
T? (nullable primitive)
| T?
| two-call pattern on property and top-level returns (forward only); method/extension nullable numerics use single-call valueOut, see Classes and objects and ADR-002/ADR-061 |
String?
| string?
| forward: two-call pattern on top-level/property returns (this page); reverse: NullableAttribute-driven, see Objects and handles and ADR-053 |
Kotlin
From test-library/src/nativeMain/kotlin/.../Mappings.kt:
fun string(): String = "Kotlin/Native!"
fun byte(): Byte = 42
fun ubyte(): UByte = 255u
fun short(): Short = 1024
fun ushort(): UShort = 65535u
fun int(): Int = 2_147_483_647
fun uint(): UInt = 4_294_967_295u
fun long(): Long = 9_223_372_036_854_775_807L
fun ulong(): ULong = 18_446_744_073_709_551_615u
fun float(): Float = 3.14f
fun double(): Double = 2.718281828459045
fun nullableInt(hasValue: Boolean): Int? = if (hasValue) 42 else null
fun nullableString(hasValue: Boolean): String? = if (hasValue) "hello" else null
Generated C#
From Interop.cs, the Mappings static class. A plain primitive return is a single [DllImport] plus an error out-parameter:
public static partial class Mappings
{
[DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "string")]
private static extern IntPtr @string_native(out IntPtr error);
public static string @string()
{
IntPtr nativeResult = @string_native(out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
return Marshal.PtrToStringUTF8(nativeResult)!;
}
[DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "byte")]
private static extern sbyte @byte_native(out IntPtr error);
public static sbyte @byte()
{
sbyte result = @byte_native(out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
return result;
}
// ... ubyte, short_, ushort, int_, uint, long_, ulong, float_, double_ follow the same shape
}
The nullable two-call pattern: a has_value probe, then a value fetch only if present. Both calls carry the same out IntPtr error out-parameter as every other sync export (ADR-024), and the wrapper checks it after each crossing, since either call can independently throw:
[DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "nullableInt_has_value")]
private static extern bool nullableInt_has_value(bool hasValue, out IntPtr error);
[DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "nullableInt_value")]
private static extern int nullableInt_value(bool hasValue, out IntPtr error);
public static int? nullableInt(bool hasValue)
{
bool __nuget_hasValue = nullableInt_has_value(hasValue, out IntPtr __nuget_error);
if (__nuget_error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(__nuget_error);
}
if (!__nuget_hasValue) return null;
int __nuget_value = nullableInt_value(hasValue, out IntPtr __nuget_error2);
if (__nuget_error2 != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(__nuget_error2);
}
return __nuget_value;
}
[DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "nullableString_has_value")]
private static extern bool nullableString_has_value(bool hasValue, out IntPtr error);
[DllImport("sample", CallingConvention = CallingConvention.Cdecl, EntryPoint = "nullableString_value")]
private static extern IntPtr nullableString_value(bool hasValue, out IntPtr error);
public static string? nullableString(bool hasValue)
{
bool __nuget_hasValue = nullableString_has_value(hasValue, out IntPtr __nuget_error);
if (__nuget_error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(__nuget_error);
}
if (!__nuget_hasValue) return null;
IntPtr __nuget_nativeResult = nullableString_value(hasValue, out IntPtr __nuget_error2);
if (__nuget_error2 != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(__nuget_error2);
}
return Marshal.PtrToStringUTF8(__nuget_nativeResult);
}
Both error out-parameters were missing until a recent fix: the two-call pattern's generated [DllImport]s never declared them, even though the native export side always writes through one, so a nullable-returning function that threw corrupted memory (SIGBUS) instead of throwing KotlinException. ADR-024's synchronous exception propagation never actually worked for nullable-returning exports until this was corrected; see NullableFunctionExceptionPropagationTests.cs for the regression coverage.
Kotlin identifiers that collide with C# keywords (string, byte, short, int, long) are escaped with @ on the C# side; short/int/long/double also get a trailing underscore on the native entry point to dodge C reserved words.
Using it from C#
From IntegrationTests/MappingTests.cs:
[Fact]
public void String_ReturnsExpectedValue()
{
string result = Mappings.@string();
Assert.Equal("Kotlin/Native!", result);
}
[Fact]
public void UInt_ReturnsExpectedValue()
{
uint result = Mappings.@uint();
Assert.Equal(4_294_967_295u, result);
}
[Fact]
public void NullableInt_WithValue_ReturnsValue()
{
int? result = Mappings.nullableInt(true);
Assert.Equal(42, result);
}
[Fact]
public void NullableInt_WithoutValue_ReturnsNull()
{
int? result = Mappings.nullableInt(false);
Assert.Null(result);
}
[Fact]
public void NullableString_WithValue_ReturnsValue()
{
string? result = Mappings.nullableString(true);
Assert.Equal("hello", result);
}
Nullable primitive and object properties on classes follow the same pattern; see Cat.Owner and Cat.Age in Classes and objects.
Char
Char is a 2-byte scalar on both sides (char in C#, KChar/unsigned short at the C ABI). It is planned like any other ordinary primitive (ADR-062): as a property, a method parameter, and a method return.
From test-library/src/nativeMain/kotlin/.../clinic/ClinicSample.kt:
class Patient(val name: String) {
val grade: Char = 'A'
fun tag(initial: Char): String = "$initial-$name"
fun initial(): Char = name.first()
}
Generated C#, from Interop.cs:
public char Grade
{
get
{
char nativeResult = Native_Get_grade(_handle, out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
return nativeResult;
}
}
public string Tag(char initial)
{
IntPtr nativeResult = Native_Tag(_handle, initial, out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
return Marshal.PtrToStringUTF8(nativeResult)!;
}
public char Initial()
{
char result = Native_Initial(_handle, out IntPtr error);
if (error != IntPtr.Zero)
{
throw NugetErrorNative.BuildException(error);
}
return result;
}
From IntegrationTests/ReturnAndPropertyMarshallingTests.cs and MethodParameterMarshallingTests.cs:
[Fact]
public void Patient_Grade_IsCharProperty()
{
using var patient = new Patient("Oreo");
Assert.Equal('A', patient.Grade);
}
[Fact]
public void Patient_Initial_ReturnsFirstCharacter()
{
using var patient = new Patient("Oreo");
Assert.Equal('O', patient.Initial());
}
[Fact]
public void Patient_Tag_MarshalsCharParameter()
{
using var patient = new Patient("Oreo");
Assert.Equal("O-Oreo", patient.Tag('O'));
}
Limitations
Nullable primitive mapping (Int?, and friends) is forward-only (→): the reverse direction has no Nullable<T> wire format yet (deferred by ADR-053 Decision 3, not a two-call-pattern gap).
Nullable string mapping is now ⇄: forward uses this page's two-call pattern on property and top-level returns, reverse reads the bound assembly's NullableAttribute instead (see Objects and handles). The two mechanisms are unrelated; a reverse-bound string? never goes through a has_value/value pair.
Nullable Boolean method returns remain unplanned under the shared callable plan: the callable is omitted and a SKIPPED_UNSUPPORTED_RETURN diagnostic names it, rather than the fallthrough-emit this used to be. Tracked in ROADMAP.md Phase 4 (ADR-062, ADR-064).
Last modified: 21 July 2026