PONY λ M2 Modula-2

Kotlin.CodeCompared.To/C#

An interactive executable cheatsheet comparing Kotlin and C#

Kotlin 2.3 C# 13
Basics & Syntax
Hello, World
fun main() { println("Hello, World!") }
Console.WriteLine("Hello, World!");
C# 9 added top-level statements, so the class Program { static void Main… } ceremony is gone — the file body is the entry point, exactly as Kotlin's main shortcut intends. Semicolons are back, though, and they are mandatory. Every example on this page is a top-level program; type declarations, when needed, go after the statements.
val and var, renamed
A genuine false friend: var means the opposite of what you think. In C# it is the type inference keyword, not the mutability keyword — and every local is mutable unless you say otherwise.
fun main() { val fixed = 1 // immutable var counter = 0 // mutable counter += 1 // fixed = 2 ← compile error val explicit: Long = 42 println("${fixed + counter} $explicit") }
var counter = 0; // "var" = INFER THE TYPE. It is mutable. counter += 1; const int Fixed = 1; // compile-time constant — literals only var alsoFixed = 3; // ...and nothing stops you reassigning this long explicitly = 42; // explicit type, no keyword needed Console.WriteLine($"{Fixed + counter} {explicitly}");
Kotlin's val/var axis (mutability) and C#'s var/explicit-type axis (inference) are orthogonal, and C# has no local-immutability keyword at all — const only takes compile-time literals, and readonly applies to fields, not locals. So the discipline val gives you for free must be maintained by hand. Note also that types come before the name (long explicitly, not explicitly: Long) — C-family word order, unlike Kotlin's Pascal-style colon.
String interpolation
fun main() { val name = "Ada" val age = 36 println("$name is $age") println("Next year: ${age + 1}") val raw = """ Dear $name, Regards """.trimIndent() println(raw) }
var name = "Ada"; var age = 36; Console.WriteLine($"{name} is {age}"); // the $ prefix is required Console.WriteLine($"Next year: {age + 1}"); Console.WriteLine($"Padded: {age,5} | Fixed: {3.14159:F2}"); var raw = $""" Dear {name}, Regards """; Console.WriteLine(raw);
The $ moves from inside the string to the front of it: C# marks the whole literal as interpolated, then every {…} is an expression slot — so a literal brace must be doubled ({{), where Kotlin escapes the dollar instead. C# adds format specifiers Kotlin punts to String.format ({age,5} pads, {value:F2} rounds). Raw string literals (""") work like Kotlin's, and better: the closing delimiter's indentation sets the margin, so trimIndent() is built into the syntax.
Loops and ranges
Kotlin's 1..5 is inclusive and iterable. C#'s ranges are a slicing feature, are half-open, and cannot be looped over — so counting means an old-fashioned for.
fun main() { for (number in 1..5) print("$number ") println() for (number in 1 until 5) print("$number ") println() for (number in 5 downTo 1 step 2) print("$number ") println() val names = listOf("Ada", "Grace") for ((index, name) in names.withIndex()) println("$index: $name") }
foreach (var number in Enumerable.Range(1, 5)) Console.Write($"{number} "); Console.WriteLine(); for (var number = 1; number < 5; number++) Console.Write($"{number} "); Console.WriteLine(); for (var number = 5; number >= 1; number -= 2) Console.Write($"{number} "); Console.WriteLine(); var names = new[] { "Ada", "Grace" }; foreach (var (index, name) in names.Select((name, index) => (index, name))) Console.WriteLine($"{index}: {name}");
There is no downTo, no step, and no inclusive range to iterate: Enumerable.Range(start, count) takes a count, not an end, and everything else is a C-style for. C# does have a range operator, but numbers[1..3] is for slicing arrays and spans (half-open, like Python), not for counting. The withIndex() idiom becomes a LINQ Select with the two-argument overload — an early taste of how much of Kotlin's stdlib lives in LINQ here.
Nullability
T? is a warning, not an error
The most important slide on this page. string? looks exactly like String? and promises far less: nullable reference types are a compiler analysis, they emit warnings, and they are opt-in.
fun main() { var name: String? = null name = "Ada" val required: String = "always here" // val broken: String = null ← COMPILE ERROR. Not negotiable. println(name?.length) println(required.length) }
#nullable enable string? name = null; name = "Ada"; string required = "always here"; string broken = null!; // a WARNING without the !, never an error Console.WriteLine(name?.Length); Console.WriteLine(required.Length); Console.WriteLine(broken is null); // true — it compiled and it IS null
Three things to internalize. (1) The check is opt-in: without #nullable enable (or the project-level <Nullable>enable</Nullable>, which modern templates do set) there is no analysis at all. (2) Violations are warnings — the program still compiles and still runs, so a non-nullable string can absolutely hold null at runtime. (3) None of it survives into the runtime: there is no Intrinsics.checkNotNull guard on public entry points, so a null from an unchecked library or from JSON deserialization sails straight into your "non-nullable" field. Treat warnings-as-errors as mandatory and you get close to Kotlin's guarantee; ship with warnings and you have documentation.
?. and ?? are right at home
The operators, at least, ported over exactly — with one extra that Kotlin lacks.
class Address(val city: String?) class Person(val address: Address?) fun main() { val person = Person(Address(null)) val city = person.address?.city ?: "unknown" println(city) println(person.address?.city?.length) var cached: String? = null cached = cached ?: "computed" println(cached) }
#nullable enable var person = new Person(new Address(null)); var city = person.Address?.City ?? "unknown"; // ?: becomes ?? Console.WriteLine(city); Console.WriteLine(person.Address?.City?.Length); string? cached = null; cached ??= "computed"; // null-coalescing ASSIGNMENT — no Kotlin equivalent Console.WriteLine(cached); record Address(string? City); record Person(Address? Address);
Safe navigation is ?. in both, and the elvis operator is spelled ?? — "null-coalescing" — with identical short-circuiting semantics. C# throws in ??=, which assigns only if the target is null (Kotlin needs value = value ?: …). ?. also chains into indexers (list?[0]) and, best of all, into delegate invocation: handler?.Invoke(this, args) is the standard null-safe event raise.
!! becomes ! (and it does nothing)
Both languages have an "I know better" operator. They behave completely differently at runtime, and the difference will eventually cost you an afternoon.
fun main() { val name: String? = null try { println(name!!.length) // throws NullPointerException, HERE, now } catch (exception: NullPointerException) { println("NPE at the assertion, exactly where I lied") } }
#nullable enable string? name = null; // The ! is the "null-forgiving" operator: it silences the COMPILER. // It emits no check, no code, nothing. It is a comment with syntax. string forgiven = name!; Console.WriteLine(forgiven is null); // true. No exception was thrown. try { Console.WriteLine(forgiven.Length); // the NRE lands HERE instead } catch (NullReferenceException) { Console.WriteLine("NullReferenceException — one line later than Kotlin"); }
Kotlin's !! compiles to a real runtime check that throws at the assertion — the lie is caught at the exact line you told it. C#'s ! is the null-forgiving operator: it emits no instructions whatsoever, merely telling the static analyzer to stop complaining. The null therefore propagates silently until something dereferences it, and the NullReferenceException surfaces somewhere downstream with a stack trace pointing at the innocent party. Reach for it as rarely as you reach for !!, and reach for ArgumentNullException.ThrowIfNull(value) when you want Kotlin's fail-fast behavior.
Classes & Properties
Primary constructors
C# 12 finally added primary constructors — but they mean something subtly different from Kotlin's.
class Person(val name: String, var age: Int) { fun greet() = "Hi, I'm $name ($age)" } fun main() { val person = Person("Ada", 36) println(person.name) // a property — constructor param became one println(person.greet()) }
var person = new Person("Ada", 36); Console.WriteLine(person.Name); Console.WriteLine(person.Greet()); class Person(string name, int age) { // The parameters are CAPTURED, not published. To expose one // as a property you must declare it: public string Name { get; } = name; public int Age { get; set; } = age; public string Greet() => $"Hi, I'm {name} ({Age})"; }
The shapes rhyme; the semantics differ. Kotlin's val name in the constructor declares a property; C# 12's primary-constructor parameters are merely captured into scope for the class body to use, and expose nothing publicly unless you write the property yourself. So class Person(string name) gives you no person.Name at all — a quietly surprising result if you assume Kotlin's rules. (Records, next section, do publish them.) Members are also private by default and need an explicit public, where Kotlin's default is public.
Properties: real syntax on both sides
Rare comfort: both languages have first-class properties, so no getter/setter boilerplate anywhere.
class Rectangle(val width: Int, val height: Int) { val area: Int get() = width * height var scale: Int = 1 set(value) { require(value > 0) { "scale must be positive" } field = value } } fun main() { val rectangle = Rectangle(3, 4) println(rectangle.area) rectangle.scale = 2 println(rectangle.scale) }
var rectangle = new Rectangle(3, 4); Console.WriteLine(rectangle.Area); rectangle.Scale = 2; Console.WriteLine(rectangle.Scale); class Rectangle(int width, int height) { private int scale = 1; public int Width { get; } = width; public int Height { get; } = height; public int Area => Width * Height; // expression-bodied get-only property public int Scale { get => scale; set { if (value <= 0) throw new ArgumentException("scale must be positive"); scale = value; } } }
Both spell the setter's incoming argument value, and both let a computed property masquerade as a field. The divergence is the backing store: Kotlin's magic field identifier is generated for you, while C# (before the very new field keyword) makes you declare the private backing field by hand as soon as the accessor does anything non-trivial. { get; set; } is the auto-property (no backing field written), { get; } and { get; init; } are the read-only forms — init being C#'s answer to val, settable during object initialization and frozen afterward.
Closed by default, on both sides
Kotlin famously made classes final unless open. C# got there first with methods, and its keywords are the ones Kotlin renamed.
open class Animal(val name: String) { open fun speak() = "..." fun describe() = "$name says ${speak()}" } class Dog(name: String) : Animal(name) { override fun speak() = "Woof" } fun main() { println(Dog("Rex").describe()) }
Console.WriteLine(new Dog("Rex").Describe()); class Animal(string name) { public string Name { get; } = name; public virtual string Speak() => "..."; // "open" is "virtual" public string Describe() => $"{Name} says {Speak()}"; } class Dog(string name) : Animal(name) { public override string Speak() => "Woof"; // "override" is "override" }
open is virtual, and override is override — mandatory in both, so no accidental overrides. Two differences: C# classes are open by default (it is methods that are closed; sealed class means final, a completely different meaning from Kotlin's sealed — see Pattern Matching), and C# lets a subclass deliberately shadow a non-virtual member with new, which Kotlin simply forbids. The base-constructor call rides on the type list (: Animal(name)), exactly as in Kotlin.
Records & Data Classes
data class becomes record
The straight port. A positional record generates the constructor, the properties, equality, hashing, and a readable ToString.
data class Point(val x: Int, val y: Int) fun main() { val point = Point(3, 4) println(point) println(point == Point(3, 4)) println(point.hashCode() == Point(3, 4).hashCode()) }
var point = new Point(3, 4); Console.WriteLine(point); Console.WriteLine(point == new Point(3, 4)); // == is VALUE equality here Console.WriteLine(point.GetHashCode() == new Point(3, 4).GetHashCode()); record Point(int X, int Y);
Where data class generates equals, hashCode, toString, and copy, record generates Equals, GetHashCode, ToString, Deconstruct, and a with clone — and it goes one better by overloading == itself, so value equality works through the operator rather than only through a method. Records are also immutable by default (the generated properties are init-only, so they really are val), and unlike Kotlin's data classes they support inheritance from other records.
copy() becomes a with expression
data class Person(val name: String, val age: Int, val city: String) fun main() { val person = Person("Ada", 36, "London") val older = person.copy(age = 37) println(older) println(person) // untouched }
var person = new Person("Ada", 36, "London"); var older = person with { Age = 37 }; Console.WriteLine(older); Console.WriteLine(person); // untouched record Person(string Name, int Age, string City);
with is copy() with nicer syntax: a shallow clone of the record with the listed properties replaced, and the original left alone. Both are shallow — a nested mutable object is shared between the copies, in C# just as in Kotlin. The advantage of the language-level form is that with works on any record without generating a method, and it plays properly with inheritance (cloning the runtime type, not the static one).
Destructuring and tuples
Kotlin destructures by position via componentN(); C# does it through a Deconstruct method — and adds real tuples, which Kotlin never got past Pair and Triple.
data class Point(val x: Int, val y: Int) fun minMax(numbers: List<Int>): Pair<Int, Int> = Pair(numbers.min(), numbers.max()) fun main() { val (x, y) = Point(3, 4) println("$x, $y") val (smallest, largest) = minMax(listOf(3, 1, 4)) println("$smallest $largest") }
var (x, y) = new Point(3, 4); // uses the generated Deconstruct Console.WriteLine($"{x}, {y}"); var (smallest, largest) = MinMax([3, 1, 4]); Console.WriteLine($"{smallest} {largest}"); (int Min, int Max) MinMax(List<int> numbers) => (numbers.Min(), numbers.Max()); record Point(int X, int Y);
Value tuples are the real upgrade: (int Min, int Max) is an ad-hoc, named, allocation-free return type — where Kotlin's Pair forces you onto .first and .second (or a whole data class) the moment you want two values back. Any type can opt into destructuring by writing a Deconstruct(out …) method, which is the counterpart of Kotlin's operator fun component1(). Note [3, 1, 4]: C# 12 collection expressions, which infer the target collection type.
Functions & Delegates
Default & named arguments
fun greet(name: String, greeting: String = "Hello", excited: Boolean = false): String { val mark = if (excited) "!" else "." return "$greeting, $name$mark" } fun main() { println(greet("Ada")) println(greet("Ada", excited = true)) }
Console.WriteLine(Greet("Ada")); Console.WriteLine(Greet("Ada", excited: true)); // colon, not equals string Greet(string name, string greeting = "Hello", bool excited = false) { var mark = excited ? "!" : "."; return $"{greeting}, {name}{mark}"; }
Same feature, one character apart: named arguments use a colon (excited: true) rather than =, which reads oddly for about a day. Both languages let you skip middle parameters this way, and both re-evaluate nothing — but C# bakes the default value into the call site at compile time, so changing a default in a library requires recompiling every caller to see it. That surprise does not exist in Kotlin, where the default is evaluated inside the callee.
Expression-bodied members
class Circle(val radius: Double) { val area get() = Math.PI * radius * radius fun scaled(factor: Double) = Circle(radius * factor) override fun toString() = "Circle($radius)" } fun double(number: Int) = number * 2 fun main() { println(double(21)) println(Circle(2.0).scaled(1.5)) }
Console.WriteLine(Double(21)); Console.WriteLine(new Circle(2.0).Scaled(1.5)); int Double(int number) => number * 2; // a LOCAL function, no class needed class Circle(double radius) { public double Radius { get; } = radius; public double Area => Math.PI * Radius * Radius; public Circle Scaled(double factor) => new(Radius * factor); public override string ToString() => $"Circle({Radius})"; }
Kotlin's = expression body is C#'s => expression body, available on methods, properties, constructors, and operators alike — the same conciseness, one arrow instead of an equals sign. Two conveniences to steal: new(…) is a target-typed new that infers the type from the left-hand side, and C# functions declared inside a method body are local functions, closing over locals with no lambda allocation. What C# does not have is top-level functions: Double above is legal only because top-level statements wrap it; in a real file, every method belongs to a type (usually a static class).
Extension functions become extension methods
Both languages have them, both resolve statically, and C# invented the idea — but its version is markedly more restricted.
fun String.shout(): String = uppercase() + "!" fun List<Int>.secondOrNull(): Int? = if (size >= 2) this[1] else null val String.initials: String get() = split(" ").map { it.first() }.joinToString("") fun main() { println("hello".shout()) println(listOf(1, 2, 3).secondOrNull()) println("Ada Lovelace".initials) }
Console.WriteLine("hello".Shout()); Console.WriteLine(new List<int> { 1, 2, 3 }.SecondOrNull()); // No extension PROPERTIES — this has to be a method call: Console.WriteLine("Ada Lovelace".Initials()); static class StringExtensions // must live in a STATIC class { public static string Shout(this string text) => text.ToUpper() + "!"; public static int? SecondOrNull(this List<int> items) => items.Count >= 2 ? items[1] : null; public static string Initials(this string text) => string.Concat(text.Split(' ').Select(word => word[0])); }
The mechanics are identical — static methods with sugar at the call site, resolved at compile time, no dynamic dispatch, and invisible to the extended type. C# demands more ceremony (a static class, a static method, the this modifier on the first parameter) and, more importantly, offers no extension properties and no extension operators, so val String.initials has to become a method call. LINQ is itself nothing but extension methods on IEnumerable<T>, which is the single best advertisement for the feature in either language.
Function types become Func and Action
Kotlin has structural function types: (Int) -> String is just a type. C# has delegates, which are named types — and two of them cover almost everything.
fun applyTwice(value: Int, transform: (Int) -> Int): Int = transform(transform(value)) fun main() { println(applyTwice(3) { it * 2 }) val describe: (Int) -> String = { number -> "n=$number" } println(describe(7)) val log: (String) -> Unit = { message -> println(message) } log("done") }
Console.WriteLine(ApplyTwice(3, value => value * 2)); Func<int, string> describe = number => $"n={number}"; // returns a value Console.WriteLine(describe(7)); Action<string> log = message => Console.WriteLine(message); // returns void log("done"); int ApplyTwice(int value, Func<int, int> transform) => transform(transform(value));
The split is the thing to memorize: Func<…, TResult> when the lambda returns a value, Action<…> when it returns nothing — because C# has no Unit type to unify them, void is not a real type. There is no it, so every parameter is named; there is no trailing-lambda syntax, so the lambda stays inside the parentheses and Kotlin's block-style DSLs have no direct analog. In exchange, delegates are multicast (you can += several handlers onto one), which is the foundation of C#'s event keyword.
Collections & LINQ
Collections and the missing read-only default
Kotlin draws a bright line between List and MutableList. C# gives you List<T>, which is always mutable, and asks you to reach for an interface if you want less.
fun main() { val numbers = listOf(1, 2, 3) // read-only interface val mutable = mutableListOf(1, 2, 3) mutable.add(4) val ages = mapOf("Ada" to 36) val unique = setOf(1, 2, 2, 3) println(numbers) println(mutable) println(ages["Ada"]) println(unique) }
List<int> mutable = [1, 2, 3]; // ALWAYS mutable mutable.Add(4); IReadOnlyList<int> numbers = [1, 2, 3]; // read-only VIEW, opt-in var ages = new Dictionary<string, int> { ["Ada"] = 36 }; var unique = new HashSet<int> { 1, 2, 2, 3 }; Console.WriteLine(string.Join(", ", numbers)); Console.WriteLine(mutable.Count); Console.WriteLine(ages["Ada"]); Console.WriteLine(unique.Count);
The default is reversed: Kotlin's listOf hands back the read-only interface and you must ask for mutability, while C#'s List<T> is the concrete mutable class and you must ask for IReadOnlyList<T>. In both cases it is a view, not a guarantee — the underlying list is still mutable through another reference — but the C# convention of exposing the concrete type on APIs means read-only collections are far rarer in practice. Note [1, 2, 3]: C# 12 collection expressions finally give literals a compact syntax, and ages["Ada"] on a missing key throws (TryGetValue is the safe form; Kotlin's get returns null).
The stdlib becomes LINQ
Every collection function you know has a LINQ counterpart with a different name. This table is most of the learning curve.
fun main() { val names = listOf("ada", "grace", "alan") println(names.filter { it.length > 3 }.map { it.uppercase() }) println(names.first { it.startsWith("a") }) println(names.firstOrNull { it.startsWith("z") }) println(names.sumOf { it.length }) println(names.any { it.length > 4 }) println(names.sortedBy { it.length }) println(names.groupBy { it.first() }) println(names.joinToString(" | ")) }
var names = new List<string> { "ada", "grace", "alan" }; Console.WriteLine(string.Join(", ", names.Where(n => n.Length > 3).Select(n => n.ToUpper()))); Console.WriteLine(names.First(n => n.StartsWith("a"))); Console.WriteLine(names.FirstOrDefault(n => n.StartsWith("z")) ?? "(none)"); Console.WriteLine(names.Sum(n => n.Length)); Console.WriteLine(names.Any(n => n.Length > 4)); Console.WriteLine(string.Join(", ", names.OrderBy(n => n.Length))); foreach (var group in names.GroupBy(n => n[0])) Console.WriteLine($"{group.Key}: {string.Join(",", group)}"); Console.WriteLine(string.Join(" | ", names));
The dictionary: map is Select, filter is Where, fold is Aggregate, flatMap is SelectMany, sortedBy is OrderBy, first is First, firstOrNull is FirstOrDefault, sumOf is Sum, and joinToString is the static string.Join — which breaks the chain and must wrap it. Watch FirstOrDefault: on a value type it returns 0, not null, so "not found" and "found a zero" are indistinguishable — SingleOrDefault has the same hazard. There is also a SQL-like query syntax (from n in names where … select …) that compiles to these same calls; almost nobody uses it.
Sequence is IEnumerable — and LINQ is already lazy
Kotlin's collection functions are eager; you opt into laziness with asSequence(). LINQ is lazy by default, which is a trap in the other direction.
fun fibonacci() = sequence { var current = 0 var next = 1 while (true) { yield(current) val sum = current + next current = next next = sum } } fun main() { println(fibonacci().take(8).toList()) // Eager: this builds a whole 1,000,000-element list first. val eager = (1..1_000_000).map { it * it }.first { it > 50 } println(eager) }
Console.WriteLine(string.Join(", ", Fibonacci().Take(8))); // LAZY by default: nothing is computed until First() pulls. var lazy = Enumerable.Range(1, 1_000_000).Select(n => n * n).First(n => n > 50); Console.WriteLine(lazy); var query = new[] { 1, 2, 3 }.Select(n => { Console.WriteLine($"evaluating {n}"); return n; }); Console.WriteLine("query built — nothing ran yet"); Console.WriteLine(query.Count()); // NOW it runs... Console.WriteLine(query.Count()); // ...and runs AGAIN. Deferred execution. IEnumerable<int> Fibonacci() { var (current, next) = (0, 1); while (true) { yield return current; (current, next) = (next, current + next); } }
IEnumerable<T> is Sequence<T>, and a method with yield return is sequence { yield(…) } — infinite generators work identically. The trap is deferred execution: a LINQ chain is a query, not a result, so it re-executes every time you enumerate it (note the two Count() calls above), and it will happily observe changes to the source collection you made after building it. Kotlin's eager default makes this impossible. The fix is a terminal .ToList() to materialize — the mirror image of the asSequence() habit.
Pattern Matching
when becomes the switch expression
Not the C-style switch statement you are bracing for — the modern switch expression is a genuine match on when's level.
fun describe(value: Any): String = when (value) { 0 -> "zero" is Int if value < 0 -> "negative int" is Int -> "int: $value" is String -> "string of ${value.length}" else -> "something else" } fun main() { println(describe(0)) println(describe(-5)) println(describe("hi")) println(describe(3.5)) }
Console.WriteLine(Describe(0)); Console.WriteLine(Describe(-5)); Console.WriteLine(Describe("hi")); Console.WriteLine(Describe(3.5)); string Describe(object value) => value switch { 0 => "zero", int number when number < 0 => "negative int", // "when" is the GUARD here int number => $"int: {number}", string text => $"string of {text.Length}", _ => "something else", };
Both are expressions that return a value, both test types and bind the narrowed value in one step (C#'s int number declaration pattern is Kotlin's smart cast, just written down), and _ is else. The cruel joke is the keyword: C# spells the guard when — so when in C# is what if does inside a Kotlin when branch. C# goes further with property patterns (Person { Age: > 65 }), list patterns ([1, .., 9]), and relational patterns (> 100) that Kotlin has no syntax for.
sealed means something else entirely
🚨 The nastiest false friend on the page. C# has the keyword sealed, and it does not mean what it means in Kotlin.
sealed class Result data class Success(val value: Int) : Result() data class Failure(val reason: String) : Result() fun describe(result: Result): String = when (result) { is Success -> "ok: ${result.value}" is Failure -> "failed: ${result.reason}" // Exhaustive. No else needed. Add a subclass and this stops compiling. } fun main() { println(describe(Success(42))) println(describe(Failure("nope"))) }
Console.WriteLine(Describe(new Result.Success(42))); Console.WriteLine(Describe(new Result.Failure("nope"))); string Describe(Result result) => result switch { Result.Success success => $"ok: {success.Value}", Result.Failure failure => $"failed: {failure.Reason}", _ => throw new ArgumentException("unreachable"), // still REQUIRED }; // C# "sealed" on a class just means FINAL (Kotlin's "not open"). // The closed-hierarchy trick is an abstract type with a private constructor, // so only its NESTED types can subclass it: abstract record Result { private Result() { } public sealed record Success(int Value) : Result; public sealed record Failure(string Reason) : Result; }
In C#, sealed class means "cannot be inherited from" — it is Kotlin's default, the opposite of open, and has nothing to do with closed hierarchies. To get the real thing you nest the subtypes inside an abstract base with a private constructor, so no outside type can join the union. Even then the compiler will not prove exhaustiveness: a switch without a _ arm compiles with a warning and throws SwitchExpressionException at runtime if nothing matches. Discriminated unions are the most-requested missing C# feature and are on the roadmap; until they land, this is the strongest gap between the two type systems.
Generics
Generics are reified — no erasure
The one place C# is flatly ahead of the JVM. Type arguments survive to runtime: no erasure, no reified keyword, no inline trick, no Class<T> parameter smuggled in beside the real ones.
// This is only possible because of "inline" + "reified": inline fun <reified T> firstOfType(items: List<Any>): T? = items.filterIsInstance<T>().firstOrNull() fun main() { val mixed = listOf(1, "two", 3.0, 4) println(firstOfType<String>(mixed)) println(firstOfType<Int>(mixed)) // At runtime, List<String> and List<Int> are the SAME type. println(listOf("a") is List<*>) }
var mixed = new List<object> { 1, "two", 3.0, 4 }; Console.WriteLine(FirstOfType<string>(mixed) ?? "(none)"); Console.WriteLine(FirstOfType<int>(mixed)); // No "inline", no "reified" — T is simply available. T? FirstOfType<T>(List<object> items) => items.OfType<T>().FirstOrDefault(); // The type argument is REAL at runtime: Console.WriteLine(typeof(List<string>)); // System.Collections.Generic.List`1[System.String] Console.WriteLine(typeof(List<string>) == typeof(List<int>)); // false!
On the JVM, List<String> and List<Int> are the same class at runtime, which is why Kotlin needs inline fun <reified T> to inline the call site and recover the type — and why is List<String> is illegal. The CLR specializes generics for real, so typeof(T), new T() (with the where T : new() constraint), and is T all just work in an ordinary method. Value types get their own specialized code too, meaning List<int> stores raw ints with no boxing — where Kotlin's List<Int> silently boxes every element into an Integer.
Variance: same keywords, declaration-site only
Kotlin borrowed out and in from C#, so declaration-site variance is spelled identically. What is missing is the use-site half.
interface Producer<out T> { // covariant fun produce(): T } interface Consumer<in T> { // contravariant fun consume(item: T) } class StringProducer : Producer<String> { override fun produce() = "hello" } fun main() { val producer: Producer<Any> = StringProducer() // safe: T only comes out println(producer.produce()) // Use-site variance — no C# equivalent: fun copyAll(source: List<out Number>) = source.map { it.toInt() } println(copyAll(listOf(1, 2, 3))) }
IProducer<object> producer = new StringProducer(); // safe: T only comes out Console.WriteLine(producer.Produce()); IConsumer<string> consumer = new ObjectConsumer(); // safe: T only goes in consumer.Consume("hi"); interface IProducer<out T> { T Produce(); } // covariant — same keyword interface IConsumer<in T> { void Consume(T item); } // contravariant class StringProducer : IProducer<string> { public string Produce() => "hello"; } class ObjectConsumer : IConsumer<object> { public void Consume(object item) => Console.WriteLine($"got {item}"); }
Identical keywords, identical rule — out for a type that only comes out (producers), in for one that only goes in (consumers) — and both are checked at the declaration. The difference is that C# has no use-site variance: Kotlin's List<out Number> parameter (its answer to Java's ? extends wildcard) has no C# spelling, so an IEnumerable<T> is covariant because its interface declares out T, and a List<T> is invariant forever. Also note that C# variance only applies to interfaces and delegates, never to classes.
Value Types & Structs
struct: real value types
Kotlin's @JvmInline value class wraps exactly one field and mostly avoids allocation. C#'s struct is the general case the JVM never had.
@JvmInline value class UserId(val raw: Int) // exactly ONE property allowed data class Point(val x: Int, val y: Int) // a heap object, always fun main() { println(UserId(7)) val first = Point(1, 2) val second = first // both names point at the SAME object println(first == second) }
Console.WriteLine(new UserId(7)); var first = new Point(1, 2); var second = first; // a COPY — Point is a value type second.X = 99; // mutating the copy leaves "first" alone Console.WriteLine($"{first.X} vs {second.X}"); readonly record struct UserId(int Raw); // any number of fields allowed struct Point(int x, int y) { public int X { get; set; } = x; public int Y { get; set; } = y; }
A struct lives inline — on the stack or embedded in its owner — and is copied on assignment, with no heap allocation and no garbage to collect. That makes List<Point> a flat block of memory rather than a list of pointers, which is why C# is at home in game engines and numeric code where the JVM must fight the allocator. The costs are real: copies are silent (mutate one and the other is unchanged — a classic bug), large structs are slower to pass than a reference, and a struct always has a zero-initialized default. readonly record struct is the sweet spot and the closest thing to value class, minus the one-field restriction.
Coroutines vs async/await
suspend becomes async Task
C# invented async/await, and Kotlin's suspend is the same idea with the color moved from the return type to the declaration. Everything looks familiar — which is why the differences below sting.
import kotlinx.coroutines.* suspend fun fetchGreeting(): String { delay(10) return "Hello from a coroutine" } fun main() = runBlocking { val message = fetchGreeting() println(message) }
var message = await FetchGreeting(); // top-level await, no runBlocking Console.WriteLine(message); async Task<string> FetchGreeting() { await Task.Delay(10); return "Hello from a Task"; }
The mapping is clean: suspend fun foo(): String is async Task<String> Foo(), delay is Task.Delay, and awaiting is await rather than a plain call. Two structural differences hide underneath. A Task is hot — it starts running the moment it is created, so var task = FetchGreeting(); without an await is already in flight, where a Kotlin suspend function does nothing until called and async is explicit about launching. And a forgotten await silently discards the result and any exception, which is why "fire and forget" bugs are a C# genre unto themselves.
awaitAll becomes Task.WhenAll
import kotlinx.coroutines.* suspend fun compute(number: Int): Int { delay(10) return number * number } fun main() = runBlocking { val results = coroutineScope { (1..4).map { number -> async { compute(number) } }.awaitAll() } println(results) }
var tasks = Enumerable.Range(1, 4).Select(Compute); // already running! var results = await Task.WhenAll(tasks); Console.WriteLine(string.Join(", ", results)); var first = await Task.WhenAny(tasks); // the race, = "select" / awaitAny Console.WriteLine($"first done: {await first}"); async Task<int> Compute(int number) { await Task.Delay(10); return number * number; }
Task.WhenAll is awaitAll and Task.WhenAny is the race — but note what is absent: the tasks began running when Select created them, not when you awaited, so there is no async { } to write and also no scope that owns them. Because C# tasks are real threadpool work, WhenAll gives you genuine parallelism across cores without a Dispatchers.Default equivalent — the CLR has no GIL and no single-threaded default.
There is no structured concurrency
The genuine loss. Kotlin scopes own their children: a scope will not return until every child finishes, one failure cancels the siblings, and canceling a scope cancels everything below it. C# has none of that built in.
import kotlinx.coroutines.* suspend fun work(number: Int): Int { delay(10L * number) if (number == 2) throw IllegalStateException("worker 2 failed") return number } fun main() = runBlocking<Unit> { try { coroutineScope { // One child failing CANCELS its siblings automatically, // and the scope cannot return while a child is still running. (1..3).map { number -> async { work(number) } }.awaitAll() } } catch (exception: IllegalStateException) { println("caught: ${exception.message} — siblings were canceled") } }
using var cancellation = new CancellationTokenSource(); try { var tasks = Enumerable.Range(1, 3).Select(n => Work(n, cancellation.Token)); await Task.WhenAll(tasks); } catch (InvalidOperationException error) { // WhenAll waits for ALL of them; nothing was canceled for us. cancellation.Cancel(); // ...so we must do it BY HAND Console.WriteLine($"caught: {error.Message} — canceled the rest myself"); } async Task<int> Work(int number, CancellationToken token) { await Task.Delay(10 * number, token); if (number == 2) throw new InvalidOperationException("worker 2 failed"); return number; }
A CancellationToken is a parameter you thread through every async signature by hand and check yourself — Kotlin's coroutines carry cancellation in the context automatically and make every suspension point a cancellation point. Nothing owns a C# Task, so one can outlive the method that started it and fail with nobody listening; Task.WhenAll waits for all of them and only then reports the first exception (the rest land in an AggregateException). The discipline Kotlin gives you by construction is, here, a convention you enforce in code review.
Errors & Exceptions
try / catch / finally
The most familiar corner: same unchecked-exception model, same keywords, and C# adds an exception filter Kotlin has no answer for.
fun parseAge(text: String): Int { val number = text.toIntOrNull() ?: throw IllegalArgumentException("not a number: $text") require(number >= 0) { "age cannot be negative" } return number } fun main() { for (input in listOf("36", "-1", "oops")) { try { println(parseAge(input)) } catch (exception: IllegalArgumentException) { println("error: ${exception.message}") } finally { // always runs } } // Result: the value-returning alternative val outcome = runCatching { parseAge("oops") } println(outcome.getOrElse { -1 }) }
foreach (var input in new[] { "36", "-1", "oops" }) { try { Console.WriteLine(ParseAge(input)); } catch (ArgumentException error) when (error.Message.Contains("negative")) { Console.WriteLine($"negative: {error.Message}"); // exception FILTER } catch (ArgumentException error) { Console.WriteLine($"error: {error.Message}"); } finally { // always runs } } int ParseAge(string text) { if (!int.TryParse(text, out var number)) throw new ArgumentException($"not a number: {text}"); if (number < 0) throw new ArgumentException("age cannot be negative"); return number; }
Neither language has checked exceptions, so the discipline is identical and only the exception names change (IllegalArgumentException is ArgumentException, IllegalStateException is InvalidOperationException, NullPointerException is NullReferenceException). C#'s catch … when (…) filter is a real upgrade: the predicate runs before the stack unwinds, so a non-match leaves the original throw site intact for the debugger. Missing, though, is runCatching/Result<T> — C# has no idiomatic value-returning error wrapper, so the TryParse/out pattern (a bool plus an out parameter) is what the standard library reaches for instead.