Kotlin vs C#: Compare Null Safety, Async Models, Ecosystems and Platform Fit

Compare Kotlin and C# by building the same packing model in both. See how their managed runtimes, null checks, async behaviour and ecosystems differ.

KnowledgeGate Team

Exam prep & CS education

Updated 24 Sep 20266 min read

Kotlin and C# both offer concise classes, nullable annotations, collection pipelines and asynchronous code. The real choice depends on compiler guarantees, runtime assumptions, libraries and deployment targets. A shared packing model makes the differences concrete. For adjacent topics, explore the wider coding and programming catalogue.

Kotlin vs C# begins with the runtime and host ecosystem

Kotlin/JVM source normally becomes JVM bytecode and runs with JVM services plus Java-library interoperability. C# source normally becomes Common Intermediate Language with metadata and runs under .NET runtime services. Both ecosystems also offer ahead-of-time and target-specific compilation, so neither "always JIT" nor "only one runtime" is accurate.

Decision point

Kotlin

C#

Usual managed runtime

JVM

.NET runtime

Primary library world

Java and JVM libraries

.NET base and package libraries

Common centre of gravity

Android and JVM services

.NET web, desktop, cloud and game code

Cross-platform route

Kotlin Multiplatform or target-specific Kotlin

Cross-platform .NET application frameworks

Libraries, deployment targets and team tooling matter more than brace style. Managed runtimes provide services such as garbage collection, not identical binaries, APIs or operational behaviour.

Model the same packing job with Kotlin data classes and C# records

Kotlin expresses the line as a data class and seeds two values:

kotlin
data class PackLine(val sku: String, val unitWeightGrams: Int, val quantity: Int)

val lines = listOf(
    PackLine("BOOK", 1_200, 3),
    PackLine("CABLE", 750, 2)
)

C# uses a sealed positional record with the same inputs:

csharp
public sealed record PackLine(string Sku, int UnitWeightGrams, int Quantity);

var lines = new[] {
    new PackLine("BOOK", 1_200, 3),
    new PackLine("CABLE", 750, 2)
};

Integer grams keep this calculation visible. Real logistics systems may require fractional weights and unit conversion.

Kotlin data classes generate component functions, copying and structural equality from primary-constructor properties. C# records generate value-based equality and support non-destructive mutation, while sealed closes this record to inheritance. Neither keyword validates quantity or establishes entity identity. The shared principles of encapsulation, abstraction and polymorphism are covered in Object Oriented Technology Explained. Both languages support object-oriented design without forcing every function into a class hierarchy.

Null safety looks similar until the guarantees are tested

The complete calculation validates each line, sums net weight, applies an optional margin, and returns the total:

kotlin
fun packedWeight(lines: List<PackLine>, safetyMarginPercent: Int?): Int {
    require(lines.all { it.quantity > 0 && it.unitWeightGrams >= 0 })
    val netWeight = lines.sumOf { it.unitWeightGrams * it.quantity }
    val percent = safetyMarginPercent ?: 0
    require(percent in 0..100)
    return netWeight + netWeight * percent / 100
}
csharp
static int PackedWeight(IEnumerable<PackLine> lines, int? safetyMarginPercent)
{
    var materialised = lines.ToList();
    if (materialised.Any(x => x.Quantity <= 0 || x.UnitWeightGrams < 0))
        throw new ArgumentException("Invalid packing line");
    int netWeight = materialised.Sum(x => x.UnitWeightGrams * x.Quantity);
    int percent = safetyMarginPercent ?? 0;
    if (percent is < 0 or > 100)
        throw new ArgumentOutOfRangeException(nameof(safetyMarginPercent));
    return netWeight + netWeight * percent / 100;
}

The C# version materialises its input once, so validation and summation inspect the same sequence. Books weigh 1,200 x 3 = 3,600 grams. Cables weigh 750 x 2 = 1,500 grams. Net weight is 3,600 + 1,500 = 5,100 grams. A 10% margin adds 5,100 x 10 / 100 = 510 grams, giving 5,100 + 510 = 5,610 grams. A null margin becomes 0%, adds 0 and returns 5,100 grams.

Kotlin's String and String? are distinct source-level types, although !!, Java platform types and interop can still admit a null failure. C# nullable reference types add flow analysis and warnings around string and string?; they do not create different runtime reference types or remove every null. Here both percentages are nullable value types, which alone says nothing about whether the reference-type systems are identical. JVM learners can review the adjacent String Handling in Java model separately.

Nullable packing trace: BOOK and CABLE give 5,100 g net, then 5,610 g with a 10 percent margin or 5,100 g when the margin is null.

Kotlin coroutines vs C# async and Task: trace the same latency

Extend the 5,610 gram job with controlled fake I/O. loadStock("BOOK") takes 180 ms and returns 8. loadContainerWeight(5_610) takes 120 ms and returns 600 grams. Sequential execution takes about 180 + 120 = 300 ms. Starting both together takes about max(180, 120) = 180 ms, plus small scheduling overhead. The result is PackingQuote(stock = 8, shippingWeightGrams = 6_210) because 5,610 + 600 = 6,210.

kotlin
suspend fun quote(): PackingQuote = coroutineScope {
    val stock = async { loadStock("BOOK") }
    val container = async { loadContainerWeight(5_610) }
    PackingQuote(stock.await(), 5_610 + container.await())
}

Here async and coroutineScope are coroutine-library constructs built around compiler-supported suspending functions.

csharp
static async Task<PackingQuote> QuoteAsync(CancellationToken ct)
{
    var stock = LoadStockAsync("BOOK", ct);
    var container = LoadContainerWeightAsync(5_610, ct);
    await Task.WhenAll(stock, container);
    return new PackingQuote(await stock, 5_610 + await container);
}

Neither suspend nor async guarantees a new thread. Kotlin's coroutineScope ties child lifetimes to the scope and cancels siblings when one fails. C# Task.WhenAll coordinates completion, but cancelling a sibling after one failure must be designed, commonly through a shared cancellation mechanism. These controlled teaching values are not platform benchmarks.

Packing quote timeline: sequential stock and container calls take about 300 ms, concurrent about 180 ms, giving 6,210 g shipping weight.

Type-system differences that surface after the first example

Feature

Kotlin

C#

Type inference

val weight = 5_610

var weight = 5_610

Closed result modelling

sealed interface plus exhaustive when

abstract record hierarchy plus switch, closed by convention

Generic runtime information

JVM arguments normally erased; selected inline functions can use reified parameters

Constructed generic type information retained at runtime

An abstract record hierarchy does not by itself provide the same universal exhaustiveness guarantee. The branch can be written explicitly in each language:

kotlin
fun format(result: PackingResult) = when (result) {
    is Packed -> "Ship ${result.grams} g"
    is OutOfStock -> "${result.sku} unavailable"
    is InvalidMargin -> "Margin ${result.percent} is outside 0..100"
}
csharp
static string Format(PackingResult result) => result switch {
    Packed(var grams) => $"Ship {grams} g",
    OutOfStock(var sku) => $"{sku} unavailable",
    InvalidMargin(var percent) => $"Margin {percent} is outside 0..100",
    _ => throw new UnreachableException()
};

Thus Packed(5_610) gives "Ship 5610 g", OutOfStock("BOOK") gives "BOOK unavailable", and InvalidMargin(125) gives "Margin 125 is outside 0..100". The C# fallback is deliberate when closure cannot be proven. Both languages also express in and out variance ideas, but their declaration and use-site rules differ. Concision does not imply weaker typing.

Ecosystems and cross-platform options: choose the centre of gravity

Treat these as strong defaults, not prohibitions:

Project centre

Strong default

Android-first application

Kotlin

JVM service consuming an existing Java library

Kotlin

Shared Kotlin business logic with accepted platform integration

Kotlin

Existing .NET service, desktop or cross-platform application stack

C#

Game project centred on a C# engine and toolchain

C#

Kotlin calls Java naturally on the JVM, but Java nullability and platform types weaken some guarantees at that boundary. C# can call other .NET languages and native libraries through defined interop layers, but ownership, layout and error conventions still require contracts.

Cross-platform does not promise one untouched UI, dependency set and deployment package for every target. The shareable code depends on platform APIs, library availability, build tooling and product requirements. Prototype the hardest external dependency before committing.

Traps and how interviews test the comparison

Claim

Why it fails

Correction

Kotlin cannot throw a null-pointer exception

!!, platform types and interop remain

Treat boundaries and assertions as nullable risks

string? is a runtime wrapper in C#

Nullable reference annotations mainly drive compiler analysis

Runtime references can still be null

Coroutines and tasks are threads

Both can suspend without occupying a thread

Discuss scheduling separately from concurrency

Cross-platform removes platform work

APIs, UI, packaging and dependencies vary

Plan and test each target integration

A useful assessment asks you to calculate 10% and null-margin weights (5,610 and 5,100 grams), then predict sequential and concurrent times (about 300 ms and 180 ms + overhead). It can also ask why Kotlin/JVM cannot generally test value is List<String> after type erasure while .NET retains constructed generic type information, or how one failed I/O child affects the other operation in each implementation.

Use Classic Programs in C, Java and Python as language-neutral prompts to reimplement. Compare tests and outputs instead of merely transliterating syntax.

Kotlin or C#: the short decision rule and next step

Choose Kotlin when Android, JVM interoperability or Kotlin Multiplatform is the project's centre of gravity. Choose C# when an existing .NET stack, its application frameworks or a C#-centred engine is central. For a greenfield backend where both fit, build the packing slice in each and compare team fluency, dependencies, deployment and failure handling. DSA using Java is an adjacent JVM and Java practice route. Coding For Placements offers C, C++, Java and Python practice.