Hello World & Basics
Hello, World
Console.WriteLine("Hello, World!"); fun main() {
println("Hello, World!")
} C# top-level statements dropped the
Main ceremony; Kotlin kept a fun main() but it is one line, with no class wrapper. println replaces Console.WriteLine, and semicolons are gone.Top-level functions
Console.WriteLine(Square(7));
static int Square(int number) => number * number; fun square(number: Int) = number * number
fun main() {
println(square(7))
} Kotlin functions live at the top level of a file — no class, no
static. The expression body = number * number is C#’s =>, with the return type inferred. Note the convention flip: functions and properties are camelCase, not PascalCase.val — the read-only local C# never had
var counter = 0; // mutable — the only kind of local
counter += 1;
const int limit = 10; // const covers compile-time constants only
Console.WriteLine($"{counter} {limit}"); fun main() {
val fixed = 10 // read-only local
var counter = 0 // mutable local
counter += 1
println("$fixed $counter")
} val is a genuine read-only local — assignable once, from any runtime expression — which C# has no equivalent for (const handles only compile-time constants). Kotlin style is val everywhere, var only where mutation is the point.Null Safety — Enforced This Time
Errors, not warnings
#nullable enable
string? nickname = null;
// nickname.Length compiles anyway — CS8602 is just a WARNING,
// and the ? annotation is erased at runtime.
Console.WriteLine(nickname ?? "none"); fun main() {
val nickname: String? = null
// println(nickname.length) // compile ERROR, not a warning
println(nickname ?: "none")
} The headline difference: C#’s nullable reference types are advisory — warnings a build can ignore, annotations the runtime never sees. Kotlin’s
String? is a different type from String, and dereferencing it without a check refuses to compile. The Elvis operator ?: is C#’s ??.Safe calls
#nullable enable
string[]? words = null;
Console.WriteLine(words?.Length ?? 0); fun main() {
val words: List<String>? = null
println(words?.size ?: 0)
} ?. transfers directly. The fallback operator changes shape: C#’s ?? becomes Kotlin’s ?: — same precedence position, same short-circuit behavior.! → !! (and this one actually checks)
#nullable enable
string? configured = "value";
string forced = configured!; // suppresses the warning; does NOTHING at runtime
Console.WriteLine(forced); fun main() {
val configured: String? = "value"
val forced: String = configured!! // throws NullPointerException if null
println(forced)
} C#’s null-forgiving
! is erased — a wrong one lets null glide on to fail somewhere else. Kotlin’s !! performs a real check and throws at that line, so the failure is immediate and localized. Both are code smells; Kotlin’s at least fails honestly.as → as?
object value = "text";
string? text = value as string; // null on failure
int? number = value as int?;
Console.WriteLine(text ?? "not a string");
Console.WriteLine(number?.ToString() ?? "not an int"); fun main() {
val value: Any = "text"
val text: String? = value as? String // null on failure
val number: Int? = value as? Int
println(text ?: "not a string")
println(number?.toString() ?: "not an int")
} Kotlin’s
as? is C#’s as — null on failure. The bare as in Kotlin is the throwing cast, C#’s (string)value. And Any is object: the root of every Kotlin type.?.let — run only when present
#nullable enable
string? entered = " hello ";
if (entered is not null)
{
Console.WriteLine(entered.Trim());
} fun main() {
val entered: String? = " hello "
entered?.let { text ->
println(text.trim())
}
} ?.let { } runs the block only when the value is non-null, with the unwrapped value as the parameter — the expression-shaped version of the is not null guard. Plain if (entered != null) also works and smart-casts (see the Sealed Types section).Strings
Interpolation without the $ prefix
var name = "Ada";
var count = 3;
Console.WriteLine($"{name} has {count} items ({count * 2} shoes)"); fun main() {
val name = "Ada"
val count = 3
println("$name has $count items (${count * 2} shoes)")
} Every Kotlin string literal interpolates — there is no opt-in
$"…" prefix. A bare identifier needs only $name; any real expression takes braces, ${expr}.Raw strings
var json = """
{ "name": "Ada" }
""";
Console.WriteLine(json); fun main() {
val json = """
{ "name": "Ada" }
""".trimIndent()
println(json)
} Both languages triple-quote raw strings. C# 11’s version strips indentation implicitly, keyed off the closing quotes; Kotlin keeps every space unless you call
trimIndent() (or trimMargin()). Kotlin raw strings still interpolate $.Common string operations
var phrase = "stitch in time";
Console.WriteLine(phrase.ToUpper());
Console.WriteLine(phrase.Contains("time"));
Console.WriteLine(string.Join("-", phrase.Split(' '))); fun main() {
val phrase = "stitch in time"
println(phrase.uppercase())
println("time" in phrase)
println(phrase.split(" ").joinToString("-"))
} ToUpper is uppercase(), membership reads naturally as "time" in phrase, and joining is an extension on the collection itself (joinToString) rather than a static string.Join.Everything Is an Expression
if is an expression — no ternary
var temperature = 30;
var description = temperature > 25 ? "hot" : "mild";
Console.WriteLine(description); fun main() {
val temperature = 30
val description = if (temperature > 25) "hot" else "mild"
println(description)
} Kotlin has no ternary operator because it does not need one:
if/else is already an expression. With block bodies, the last expression of each branch is the value.switch expression → when
var status = 404;
var message = status switch
{
200 => "ok",
301 or 302 => "redirect",
>= 400 and < 500 => "client error",
_ => "other",
};
Console.WriteLine(message); fun main() {
val status = 404
val message = when (status) {
200 -> "ok"
301, 302 -> "redirect"
in 400..499 -> "client error"
else -> "other"
}
println(message)
} when is the closest cousin of C# 8’s switch expression: comma-separated alternatives replace or patterns, ranges are the literal in 400..499, and else plays _. There is no fallthrough anywhere in the language.when over types
object[] items = { 42, "text", 3.14 };
foreach (var item in items)
{
var described = item switch
{
int number => $"int {number}",
string text => $"string of length {text.Length}",
_ => "something else",
};
Console.WriteLine(described);
} fun main() {
val items: List<Any> = listOf(42, "text", 3.14)
for (item in items) {
val described = when (item) {
is Int -> "Int $item"
is String -> "String of length ${item.length}"
else -> "something else"
}
println(described)
}
} A C# type pattern must bind a fresh name (
int number); Kotlin’s is Int -> smart-casts item itself inside the branch — item.length compiles because the compiler already knows it is a String there.try is an expression too
var input = "not a number";
int parsed;
try
{
parsed = int.Parse(input);
}
catch (FormatException)
{
parsed = 0;
}
Console.WriteLine(parsed); fun main() {
val input = "not a number"
val parsed = try {
input.toInt()
} catch (exception: NumberFormatException) {
0
}
println(parsed)
} Because
try yields a value, the result lands in a val with no mutable placeholder declared outside the block. (For this particular job, input.toIntOrNull() ?: 0 is more idiomatic still — the stdlib prefers null returns over exceptions for parsing.)Collections & LINQ → Operators
Read-only is the default
var numbers = new List<int> { 1, 2, 3 }; // always mutable
numbers.Add(4);
IReadOnlyList<int> view = numbers; // read-only VIEW, opt-in
Console.WriteLine(string.Join(", ", view)); fun main() {
val numbers = listOf(1, 2, 3) // read-only by default
val editable = mutableListOf(1, 2, 3) // mutability is the opt-in
editable.add(4)
println(numbers)
println(editable)
} The polarity flips:
listOf returns a List interface with no mutating members at all — not a wrapper view over something mutable, a different type. mutableListOf is the explicit choice, the mirror image of C# reaching for IReadOnlyList.Where/Select → filter/map
var numbers = new[] { 1, 2, 3, 4, 5, 6 };
var result = numbers
.Where(number => number % 2 == 0)
.Select(number => number * 10)
.Sum();
Console.WriteLine(result); fun main() {
val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = numbers
.filter { number -> number % 2 == 0 }
.map { number -> number * 10 }
.sum()
println(result)
} The whole LINQ vocabulary exists under functional names:
Where → filter, Select → map, First → first, GroupBy → groupBy. The lambda moves outside the parentheses (trailing-lambda syntax), and empty parentheses disappear entirely.Eager by default; sequences are the LINQ
var firstThree = Enumerable.Range(1, 1000000)
.Where(number => number % 7 == 0)
.Select(number => number * 2)
.Take(3); // deferred — nothing runs until enumeration
Console.WriteLine(string.Join(", ", firstThree)); fun main() {
val firstThree = (1..1_000_000).asSequence()
.filter { number -> number % 7 == 0 }
.map { number -> number * 2 }
.take(3)
.toList() // the terminal operation executes the pipeline
println(firstThree)
} The performance instinct to recalibrate: LINQ is lazy by default, but Kotlin collection operators are eager — each step materializes a new list.
asSequence() opts back into LINQ-style deferred, element-at-a-time evaluation, worth it for long chains or early-exit operations like take.Dictionary → Map
var stock = new Dictionary<string, int> { ["apples"] = 5, ["pears"] = 2 };
stock["plums"] = 7;
foreach (var (fruit, quantity) in stock)
{
Console.WriteLine($"{fruit}: {quantity}");
} fun main() {
val stock = mutableMapOf("apples" to 5, "pears" to 2)
stock["plums"] = 7
for ((fruit, quantity) in stock) {
println("$fruit: $quantity")
}
} The infix
to builds a Pair, indexers work both ways, and destructuring in the loop matches C#’s tuple deconstruction. One guarantee C# never gives: mutableMapOf is backed by LinkedHashMap, so iteration order is insertion order, always.Real range syntax
foreach (var count in Enumerable.Range(1, 5))
{
Console.Write($"{count} ");
}
Console.WriteLine();
for (var count = 10; count >= 0; count -= 2)
{
Console.Write($"{count} ");
}
Console.WriteLine(); fun main() {
for (count in 1..5) {
print("$count ")
}
println()
for (count in 10 downTo 0 step 2) {
print("$count ")
}
println()
} Ranges are language syntax:
1..5 is inclusive of both ends (note the off-by-one trap against Enumerable.Range(start, count)), ..< excludes the end, and downTo/step replace the C-style descending loop.Functions & Lambdas
Named & default arguments
Console.WriteLine(Resize(width: 800, height: 600));
static string Resize(int width, int height, bool preserveAspect = true) =>
$"{width}x{height} preserve={preserveAspect}"; fun resize(width: Int, height: Int, preserveAspect: Boolean = true) =
"${width}x$height preserve=$preserveAspect"
fun main() {
println(resize(width = 800, height = 600))
} A near-perfect rhyme — both languages have named and default arguments. The only visible difference is the separator: C# names with
width:, Kotlin with width =. Because defaults exist, Kotlin has almost no method overloading in practice.Extension methods, minus the ceremony
Console.WriteLine("racecar".IsPalindrome());
static class StringExtensions
{
public static bool IsPalindrome(this string text) =>
text.SequenceEqual(text.Reverse());
} fun String.isPalindrome(): Boolean = this == this.reversed()
fun main() {
println("racecar".isPalindrome())
} No static class, no
this parameter — fun ReceiverType.name() declares an extension anywhere a function can live. Kotlin also has extension properties (val String.wordCount get() = …), which C# extension methods cannot express.Lambdas & it
var numbers = new[] { 1, 2, 3 };
var doubled = numbers.Select(number => number * 2);
Console.WriteLine(string.Join(", ", doubled)); fun main() {
val doubled = listOf(1, 2, 3).map { it * 2 }
println(doubled)
} Braces are the lambda literal, and a single parameter needs no declaration at all —
it is the implicit name. For anything beyond a short expression, naming the parameter (number ->) is better style.Func/Action → (Int) -> Int
Func<int, int> increment = number => number + 1;
Action<string> report = message => Console.WriteLine(message);
Console.WriteLine(increment(41));
report("done"); fun main() {
val increment: (Int) -> Int = { number -> number + 1 }
val report: (String) -> Unit = { message -> println(message) }
println(increment(41))
report("done")
} Function types are structural syntax, not a delegate family:
(Int) -> Int replaces Func<int, int>. Because Unit is a real type (void-as-a-value), the separate Action family is unnecessary — a returning-nothing function is just (String) -> Unit.Classes & Properties
Primary constructors that declare properties
var rectangle = new Rectangle(3, 4);
Console.WriteLine(rectangle.Area);
class Rectangle(double width, double height)
{
public double Width { get; } = width;
public double Height { get; } = height;
public double Area => Width * Height;
} class Rectangle(val width: Double, val height: Double) {
val area: Double get() = width * height
}
fun main() {
val rectangle = Rectangle(3.0, 4.0)
println(rectangle.area)
} C# 12 primary constructor parameters are still just parameters — the properties are written separately. Kotlin’s
val/var in the constructor header declares real properties in one stroke. Two more surprises: no new keyword, and no implicit Int→Double conversion — Rectangle(3, 4) would not compile.Properties with custom accessors
var thermostat = new Thermostat { Celsius = 25 };
Console.WriteLine(thermostat.Fahrenheit);
class Thermostat
{
public double Celsius { get; set; }
public double Fahrenheit
{
get => Celsius * 9 / 5 + 32;
set => Celsius = (value - 32) * 5 / 9;
}
} class Thermostat {
var celsius: Double = 0.0
var fahrenheit: Double
get() = celsius * 9 / 5 + 32
set(value) { celsius = (value - 32) * 5 / 9 }
}
fun main() {
val thermostat = Thermostat()
thermostat.celsius = 25.0
println(thermostat.fahrenheit)
} Properties transfer almost verbatim — accessors nest under the declaration, and a custom accessor can name its backing field via the
field identifier. What is missing is C#’s object-initializer syntax { Celsius = 25 }; Kotlin’s answer is apply { } (see Scope Functions).final by default — classes too
var animals = new List<Animal> { new Animal(), new Dog() };
foreach (var animal in animals)
{
Console.WriteLine(animal.Speak());
}
class Animal
{
public virtual string Speak() => "...";
}
class Dog : Animal
{
public override string Speak() => "Woof";
} open class Animal {
open fun speak(): String = "..."
}
class Dog : Animal() {
override fun speak(): String = "Woof"
}
fun main() {
val animals: List<Animal> = listOf(Animal(), Dog())
for (animal in animals) {
println(animal.speak())
}
} C# methods are sealed-unless-
virtual; Kotlin extends that stance to the class itself — nothing can be subclassed or overridden without an explicit open. The parentheses in : Animal() are the superclass constructor call, made visible in the supertype list.record → data class
var original = new Person("Ada", 36);
var older = original with { Age = 37 };
Console.WriteLine(original);
Console.WriteLine(older);
Console.WriteLine(original == older);
record Person(string Name, int Age); data class Person(val name: String, val age: Int)
fun main() {
val original = Person("Ada", 36)
val older = original.copy(age = 37)
println(original)
println(older)
println(original == older)
} data class is the record: generated value equality, toString, and destructuring. C#’s with { Age = 37 } expression becomes the generated copy(age = 37) method with named arguments — same non-destructive mutation, method call instead of syntax.== calls equals — always
var first = new List<int> { 1, 2, 3 };
var second = new List<int> { 1, 2, 3 };
Console.WriteLine(first == second); // False — reference comparison
Console.WriteLine(first.SequenceEqual(second)); // True fun main() {
val first = listOf(1, 2, 3)
val second = listOf(1, 2, 3)
println(first == second) // true — == always calls equals()
println(first === second) // false — reference identity is ===
} A deep instinct inverts: Kotlin’s
== is always structural (it compiles to equals()), on every type, with no operator overloading required. Reference identity gets its own operator, ===. In C#, == on classes defaults to reference comparison unless someone overloaded it.No static: object & companion
static members → companion object
Console.WriteLine(Temperature.FromFahrenheit(212).Celsius);
class Temperature
{
public double Celsius { get; }
private Temperature(double celsius) => Celsius = celsius;
public static Temperature FromFahrenheit(double fahrenheit) =>
new((fahrenheit - 32) * 5 / 9);
} class Temperature private constructor(val celsius: Double) {
companion object {
fun fromFahrenheit(fahrenheit: Double) =
Temperature((fahrenheit - 32) * 5 / 9)
}
}
fun main() {
println(Temperature.fromFahrenheit(212.0).celsius)
} Kotlin has no
static keyword. The companion object is a singleton attached to the class that holds the would-be statics — call sites still read Temperature.fromFahrenheit(…). Note where the private constructor lands: inline in the class header.The singleton is a keyword
Counter.Instance.Increment();
Counter.Instance.Increment();
Console.WriteLine(Counter.Instance.Count);
class Counter
{
public static Counter Instance { get; } = new();
public int Count { get; private set; }
public void Increment() => Count += 1;
} object Counter {
var count = 0
private set
fun increment() { count += 1 }
}
fun main() {
Counter.increment()
Counter.increment()
println(Counter.count)
} An
object declaration is a language-level singleton — the hand-written Instance pattern compressed into one keyword, initialized lazily and thread-safely. private set under the property mirrors { get; private set; }.Sealed Types & Smart Casts
Sealed hierarchies: exhaustive without _
Shape shape = new Circle(2);
var area = shape switch
{
Circle circle => Math.PI * circle.Radius * circle.Radius,
Rectangle rectangle => rectangle.Width * rectangle.Height,
_ => throw new InvalidOperationException("unknown shape"),
};
Console.WriteLine($"{area:F2}");
abstract record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double Width, double Height) : Shape; sealed interface Shape
data class Circle(val radius: Double) : Shape
data class Rectangle(val width: Double, val height: Double) : Shape
fun main() {
val shape: Shape = Circle(2.0)
val area = when (shape) {
is Circle -> Math.PI * shape.radius * shape.radius
is Rectangle -> shape.width * shape.height
}
println("%.2f".format(area))
} Kotlin’s
sealed means the compiler knows every subtype, so the when needs no else — and adding a third shape breaks this expression at compile time until it is handled. A C# switch over a hierarchy cannot prove exhaustiveness, hence the defensive _ arm. Note the smart cast: shape.radius, no binding variable.Pattern variables → smart casts
PrintLength("hello");
PrintLength(42);
static void PrintLength(object value)
{
if (value is string text)
{
Console.WriteLine(text.Length); // needs the new name 'text'
}
else
{
Console.WriteLine("not a string");
}
} fun printLength(value: Any) {
if (value is String) {
println(value.length) // value itself is a String here — no new name
} else {
println("not a string")
}
}
fun main() {
printLength("hello")
printLength(42)
} C# type patterns introduce a second name for the narrowed value; Kotlin flows the narrowed type through the same variable. The compiler tracks it through
&&, when branches, and null checks alike — with the caveat that only stable values (vals, not open properties) smart-cast.Deconstruct → componentN
var (name, age) = new Person("Ada", 36);
Console.WriteLine($"{name}, {age}");
record Person(string Name, int Age); data class Person(val name: String, val age: Int)
fun main() {
val (name, age) = Person("Ada", 36)
println("$name, $age")
} The syntax is identical. The mechanism differs in name only: records generate
Deconstruct, data classes generate component1()/component2() — which is also what powers destructuring in for ((key, value) in map) loops.Scope Functions
Object initializers → apply
var settings = new Settings { Theme = "dark", FontSize = 14 };
Console.WriteLine($"{settings.Theme} {settings.FontSize}");
class Settings
{
public string Theme { get; set; } = "light";
public int FontSize { get; set; } = 12;
} class Settings {
var theme: String = "light"
var fontSize: Int = 12
}
fun main() {
val settings = Settings().apply {
theme = "dark"
fontSize = 14
}
println("${settings.theme} ${settings.fontSize}")
} apply { } is Kotlin’s object initializer: the block runs with the new instance as this (so bare property names assign), and the expression returns the configured object. Unlike C#’s initializer syntax, it works on any expression, not just constructions.The scope-function family
var input = " 42 ";
var trimmed = input.Trim(); // step by step,
var parsed = int.Parse(trimmed); // temporary names
var doubledValue = parsed * 2;
Console.WriteLine(doubledValue); fun main() {
val doubledValue = " 42 "
.let { text -> text.trim() }
.let { trimmed -> trimmed.toInt() * 2 }
println(doubledValue)
} The scope functions are a signature Kotlin idiom with no C# counterpart:
let (transform it), run (transform this), apply (configure, return the object), also (side effect, return the object), with. They turn temporary-variable sequences into pipelines — used sparingly, they read beautifully; nested, they become soup.Delegation
Lazy<T> → by lazy
var report = new Lazy<string>(() =>
{
Console.WriteLine("computing...");
return "the report";
});
Console.WriteLine("before access");
Console.WriteLine(report.Value); val report: String by lazy {
println("computing...")
"the report"
}
fun main() {
println("before access")
println(report)
} by lazy makes the laziness invisible at every use site — the property’s type is plain String, no .Value unwrapping. It is one instance of property delegation, a general mechanism (Delegates.observable, map-backed properties, custom delegates) with no C# analog.Interface delegation with by
var logger = new TimestampLogger(new ConsoleLogger());
logger.Log("started");
interface ILogger { void Log(string message); }
class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine($"log: {message}");
}
class TimestampLogger(ILogger inner) : ILogger
{
public void Log(string message) => inner.Log($"[stamped] {message}");
} interface Logger {
fun log(message: String)
}
class ConsoleLogger : Logger {
override fun log(message: String) = println("log: $message")
}
class ForwardingLogger(delegate: Logger) : Logger by delegate
fun main() {
val logger = ForwardingLogger(ConsoleLogger())
logger.log("started")
} Logger by delegate generates every forwarding member automatically — composition over inheritance without hand-writing the pass-through methods C# requires. Override only the members you want to change; the rest keep forwarding.Generics
Reified natively → reified by inlining
// .NET generics are reified: typeof(T) always works, at runtime,
// in any generic method.
Console.WriteLine(DescribeType<string>());
Console.WriteLine(DescribeType<int>());
static string DescribeType<T>() => typeof(T).Name; // JVM generics are ERASED — T is gone at runtime. inline + reified
// recovers it by pasting the function body at each call site.
inline fun <reified T> describeType(): String = T::class.simpleName ?: "unknown"
fun main() {
println(describeType<String>())
println(describeType<Int>())
} A rare inversion where C# has the stronger runtime: .NET reifies generics natively, while the JVM erases them. Kotlin’s
inline fun <reified T> recovers T only because inlining copies the body — with a concrete type — into every call site. Outside an inline function, T::class does not compile.out/in — the same keywords
IEnumerable<string> words = new List<string> { "covariant" };
IEnumerable<object> values = words; // IEnumerable<out T> — covariance
Console.WriteLine(values.Count()); fun main() {
val words: List<String> = listOf("covariant")
val values: List<Any> = words // List is declared List<out E>
println(values.size)
} Kotlin borrowed C#’s exact keywords:
out for covariance, in for contravariance, declared at the type definition. Kotlin extends the idea past interfaces and delegates to classes, and adds use-site projections (Array<out Number>) for types that declare no variance.Error Handling
try/catch/finally
try
{
throw new InvalidOperationException("engine offline");
}
catch (InvalidOperationException exception)
{
Console.WriteLine($"caught: {exception.Message}");
}
finally
{
Console.WriteLine("cleanup");
} fun main() {
try {
throw IllegalStateException("engine offline")
} catch (exception: IllegalStateException) {
println("caught: ${exception.message}")
} finally {
println("cleanup")
}
} The model matches C# exactly: all exceptions are unchecked (Kotlin deliberately dropped Java’s checked exceptions),
finally works the same, and the catch clause reads (name: Type). Message becomes the property message, and the JVM names differ (IllegalStateException ≈ InvalidOperationException).TODO() and the Nothing type
Console.WriteLine(Describe(-5));
Console.WriteLine(Describe(0));
static string Describe(int value) =>
value switch
{
< 0 => "negative",
0 => "zero",
_ => throw new NotImplementedException("positive numbers still unhandled"),
}; fun describe(value: Int): String = when {
value < 0 -> "negative"
value == 0 -> "zero"
else -> TODO("positive numbers still unhandled")
}
fun main() {
println(describe(-5))
println(describe(0))
} TODO() throws NotImplementedError, but its return type is Nothing — the type of "never returns" — so it type-checks in any position, exactly like a throw expression in a C# switch arm. Also shown: a subject-less when is Kotlin’s if/else-if chain.Coroutines vs async/await
async Task<T> → suspend fun
var greeting = await FetchGreetingAsync();
Console.WriteLine(greeting);
static async Task<string> FetchGreetingAsync()
{
await Task.Delay(50);
return "hello from a task";
} import kotlinx.coroutines.*
suspend fun fetchGreeting(): String {
delay(50)
return "hello from a coroutine"
}
fun main() = runBlocking {
println(fetchGreeting())
} suspend replaces async, and — the striking part — calls need no await: a suspend call looks like a plain call, with suspension implicit. The return type stays honest too: String, not Task<string>. runBlocking bridges from the non-suspending world at the entry point.Task.WhenAll → async/await in a scope
var results = await Task.WhenAll(SquareAsync(3), SquareAsync(4));
Console.WriteLine(string.Join(", ", results));
static async Task<int> SquareAsync(int value)
{
await Task.Delay(10);
return value * value;
} import kotlinx.coroutines.*
suspend fun square(value: Int): Int {
delay(10)
return value * value
}
fun main() = runBlocking {
val first = async { square(3) }
val second = async { square(4) }
println(listOf(first.await(), second.await()))
} async { } starts a Deferred (≈ Task<T>) — but inside a scope that owns it: if one child fails, the scope cancels its siblings, and runBlocking cannot exit leaving orphans behind. That structure is what C# approximates with Task.WhenAll discipline. await() is a method here, not a keyword.Task.Run → launch
var worker = Task.Run(() => Console.WriteLine("working in the pool"));
await worker;
Console.WriteLine("done"); import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
println("working in a coroutine")
}
job.join()
println("done")
} launch is the fire-and-forget side (it returns a Job, no value) to async’s Deferred. Unlike Task.Run, launching does not imply a thread pool — coroutines are cheap enough to start thousands, and they stay on the current dispatcher unless one is specified.