PONY λ M2 Modula-2

Dart.CodeCompared.To/Kotlin

An interactive executable cheatsheet comparing Dart and Kotlin

Dart 3.7 Kotlin 2.3
Basics & Running
Hello, World
void main() { print('Hello, World!'); }
fun main() { println("Hello, World!") }
The shapes line up almost exactly: a top-level entry function and a print call. Kotlin declares functions with fun, the entry point returns Unit (its void) implicitly, printing is println, and statements need no semicolons. The convention is four-space indentation, same as Dart.
final/var becomes val/var
void main() { final answer = 42; var counter = 0; counter += 1; print(answer + counter); }
fun main() { val answer = 42 var counter = 0 counter += 1 println(answer + counter) }
A clean one-to-one map: Dart's final is val and var is var, both with the same "the binding is fixed, the object may still mutate" caveat. Kotlin's const val is narrower than Dart's compile-time const — it applies only to top-level or companion object primitives and Strings, and does not canonicalize objects.
String interpolation (identical)
void main() { final name = 'Ada'; final year = 1843; print('$name wrote the first program in $year.'); print('Next year: ${year + 1}'); }
fun main() { val name = "Ada" val year = 1843 println("$name wrote the first program in $year.") println("Next year: ${year + 1}") }
This is the rare feature that ports character for character: $name for a bare variable and ${expression} for anything larger, inside ordinary double-quoted strings. Kotlin adds triple-quoted raw strings ("""…""") for multi-line text without escapes, which Dart approximates with its own triple-quoted strings.
Numbers are typed and sized
void main() { int whole = 7 ~/ 2; // integer division operator double exact = 7 / 2; // / always yields a double num either = 5; // the shared supertype print('$whole $exact $either'); }
fun main() { val whole = 7 / 2 // Int / Int truncates -> 3 val exact = 7.0 / 2 // force a Double for 3.5 val big: Long = 5_000_000_000L println("$whole $exact $big") }
Dart's int/double/num trio becomes Kotlin's fixed-width Int, Long, Double, Float with no shared num supertype and no implicit widening. There is no ~/: / on two Ints already truncates, and you force floating-point by making one operand a Double. Literals carry suffixes (5L, 1.5f) and allow _ separators.
Null Safety & the JVM Hole
Nullable types & smart casts
String? findName(int id) => id == 1 ? 'Ada' : null; void main() { final name = findName(1); if (name != null) { print(name.length); // promotion after the null check } }
fun findName(id: Int): String? = if (id == 1) "Ada" else null fun main() { val name = findName(1) if (name != null) { println(name.length) // smart cast to non-null String } }
Nullability with a trailing ? is identical, and Dart's flow promotion is Kotlin's smart cast: once you test name != null, the compiler treats it as non-null in that scope. One subtlety — a smart cast only holds for a val (or a local that cannot change between the check and the use), because a mutable property could be reassigned on another thread, which brings us to the JVM hole below.
?? is ?:, and ! is !!
int? maybe() => null; void main() { final value = maybe() ?? -1; // ?? default print(value); final forced = (maybe() ?? 0)!; // ! asserts non-null (runtime check) print(forced); }
fun maybe(): Int? = null fun main() { val value = maybe() ?: -1 // ?: is the Elvis operator (Dart's ??) println(value) val forced = maybe() ?: 0 println(forced!!) // !! is Dart's ! (throws if null) }
The safe-call ?. is the same in both, but the other two operators rename. Dart's ?? (and ??=) is Kotlin's Elvis ?:. Dart's single-bang ! non-null assertion is Kotlin's double-bang !!, and it behaves the same way — a genuine runtime check that throws (a NullPointerException) when the value is null.
late → lateinit / by lazy
class Service { late final String endpoint; // assigned once, later late final int port = _compute(); // lazy: computed on first read int _compute() => 8080; void configure(String url) => endpoint = url; } void main() { final service = Service(); service.configure('api.example'); print('${service.endpoint}:${service.port}'); }
class Service { lateinit var endpoint: String // assigned once, later val port: Int by lazy { compute() } // lazy: computed on first read private fun compute() = 8080 fun configure(url: String) { endpoint = url } } fun main() { val service = Service() service.configure("api.example") println("${service.endpoint}:${service.port}") }
Dart's late splits into two Kotlin tools. Deferred-but-eager becomes lateinit var (non-null, throws if read before assignment) — note it must be a var and cannot hold a primitive like Int. Dart's lazy late (compute on first read) becomes a delegated property, val x by lazy { … }, which memoizes the first evaluation.
The Java interop hole
Dart's null safety is sound end to end. Kotlin's is sound within Kotlin — but a value crossing from Java arrives as a platform type whose nullability is unknown, and the compiler waves it through.
void main() { // Dart has no unchecked foreign boundary: a String is a String, // never a "maybe null that the compiler stopped tracking". final List<String> names = ['Ada', 'Grace']; print(names.map((name) => name.length).toList()); }
fun main() { // System.getenv returns a Java String! (platform type). The compiler // lets you treat it as non-null OR nullable — and picks wrong at your peril. val home: String = System.getenv("HOME") ?: "unknown" println(home.isNotEmpty()) }
This has no Dart equivalent because Dart has no unchecked foreign boundary. A type written String! in Kotlin tooling is a platform type: a value from Java whose nullness the compiler cannot verify, so it permits both a non-null and a nullable use and defers the check to run time. The habit to build is to immediately pin platform values with ?: or an explicit nullable annotation, as above.
Data Classes (the gift)
data class — the gift Dart lacks
In Dart you hand-write ==, hashCode, toString, and a copyWith (or run the freezed generator). Kotlin gives you all of them from one keyword.
class Point { final int x, y; const Point(this.x, this.y); @override bool operator ==(Object other) => other is Point && other.x == x && other.y == y; @override int get hashCode => Object.hash(x, y); @override String toString() => 'Point($x, $y)'; Point copyWith({int? x, int? y}) => Point(x ?? this.x, y ?? this.y); } void main() { final a = Point(1, 2); print(a == Point(1, 2)); // true print(a.copyWith(y: 9)); }
data class Point(val x: Int, val y: Int) fun main() { val a = Point(1, 2) println(a == Point(1, 2)) // true — structural equals is generated println(a.copy(y = 9)) // copy() is generated println(a) // toString() is generated: Point(x=1, y=2) }
A data class generates structural equals/hashCode, a readable toString, a copy(…), and componentN destructuring — replacing the entire block of Dart boilerplate on the left. This is the strongest single reason a Dart developer feels relief in Kotlin, and it is why freezed exists on the Dart side to fake it.
copy() is shallow, and it is a reference
class Team { final String name; final List<String> members; const Team(this.name, this.members); } void main() { final first = Team('A', ['Ada']); final second = first; // same object — a reference second.members.add('Grace'); // mutates the shared list print(first.members); // [Ada, Grace] — aliasing }
data class Team(val name: String, val members: MutableList<String>) fun main() { val first = Team("A", mutableListOf("Ada")) val second = first.copy() // NEW Team... second.members.add("Grace") // ...but members is the SAME list println(first.members) // [Ada, Grace] — shallow copy aliases }
Do not read data class as a value type. Like a Dart class it is a reference, so assignment aliases, and the generated copy() is shallow — it duplicates the top-level fields but shares any nested mutable objects, exactly as the Dart version on the left does. If you want deep immutability, use read-only collection types (next section) or copy the nested structures yourself.
Classes & Objects
Constructors & properties
class Rectangle { final int width; final int height; Rectangle(this.width, this.height); int get area => width * height; } void main() { print(Rectangle(3, 4).area); }
class Rectangle(val width: Int, val height: Int) { val area: Int get() = width * height } fun main() { println(Rectangle(3, 4).area) }
Kotlin folds the constructor into the class header — the primary constructor — and a val/var parameter there declares the property, the same shorthand as Dart's this.width. A computed getter is val area: Int get() = …. Instantiation needs no new keyword (Kotlin dropped it), so Rectangle(3, 4) reads just like Dart.
Named & factory constructors → companion
class Temperature { final double celsius; Temperature(this.celsius); Temperature.fahrenheit(double f) : celsius = (f - 32) * 5 / 9; factory Temperature.boiling() => Temperature(100); } void main() { print(Temperature.fahrenheit(212).celsius); print(Temperature.boiling().celsius); }
class Temperature(val celsius: Double) { companion object { fun fahrenheit(f: Double) = Temperature((f - 32) * 5 / 9) fun boiling() = Temperature(100.0) } } fun main() { println(Temperature.fahrenheit(212.0).celsius) println(Temperature.boiling().celsius) }
Kotlin has no named constructors and no factory keyword. Both patterns become functions on a companion object — a single per-class singleton whose members are called as Temperature.fahrenheit(…), reading almost identically to Dart's named constructor at the call site. The companion object is also where you put what Dart would mark static.
Cascades → scope functions
class Basket { final List<String> items = []; void add(String item) => items.add(item); } void main() { final basket = Basket() ..add('apple') ..add('pear') ..add('plum'); // cascade fires calls at one receiver print(basket.items); }
class Basket { val items = mutableListOf<String>() fun add(item: String) { items.add(item) } } fun main() { val basket = Basket().apply { add("apple") add("pear") add("plum") // apply runs a block with 'this' = the receiver } println(basket.items) }
Kotlin has no cascade operator, but its scope functions fill the gap. apply { … } runs a block with the receiver as this and returns the receiver — the direct stand-in for Dart's ... Its relatives cover the neighboring cases: also { it -> … } (receiver as it, returns receiver), and let/run/with for transform-and-return.
Operator overloading
class Vector { final int x, y; const Vector(this.x, this.y); Vector operator +(Vector other) => Vector(x + other.x, y + other.y); @override String toString() => '($x, $y)'; } void main() { print(Vector(1, 2) + Vector(3, 4)); }
data class Vector(val x: Int, val y: Int) { operator fun plus(other: Vector) = Vector(x + other.x, y + other.y) } fun main() { println(Vector(1, 2) + Vector(3, 4)) // calls plus(); prints Vector(x=4, y=6) }
Both languages let a class define +, and both do it by name-mapping. Dart writes operator +; Kotlin writes operator fun plus (there is a fixed table: plus, minus, times, get, compareTo, and so on). One difference to note: Kotlin's == always routes through equals (structural), and referential identity is the separate === — the inverse spelling of what some languages do.
Mixins → interface delegation
mixin Greetable { String get who; String greet() => 'Hello, $who'; } class Person with Greetable { @override final String who; Person(this.who); } void main() => print(Person('Ada').greet());
interface Greetable { val who: String fun greet() = "Hello, $who" // default method in the interface } class Person(override val who: String) : Greetable fun main() = println(Person("Ada").greet())
Kotlin has no mixin keyword. Shared behavior lives in an interface with default method bodies (which Dart mixins resemble), and a class opts in by implementing it. For composing state, Kotlin adds delegationclass Person(d: Greetable) : Greetable by d forwards the interface to a held object — a more explicit alternative to Dart's mixin linearization.
Functions & Lambdas
Named arguments & defaults
String greet({required String name, String greeting = 'Hello'}) => '$greeting, $name!'; void main() { print(greet(name: 'Ada')); print(greet(name: 'Ada', greeting: 'Hi')); }
fun greet(name: String, greeting: String = "Hello") = "$greeting, $name!" fun main() { println(greet(name = "Ada")) println(greet(name = "Ada", greeting = "Hi")) }
Kotlin has named arguments and default values, so this ports directly — with a spelling change and a philosophy change. Named arguments use =, not :. And there is no required keyword: a parameter is required simply by having no default, and any parameter (positional or not) may be passed by name at the call site.
Lambdas, trailing lambda & it
void main() { final numbers = [1, 2, 3, 4]; final evens = numbers.where((n) => n.isEven).toList(); final doubled = numbers.map((n) => n * 2).toList(); print('$evens $doubled'); }
fun main() { val numbers = listOf(1, 2, 3, 4) val evens = numbers.filter { it % 2 == 0 } // trailing lambda + 'it' val doubled = numbers.map { it * 2 } println("$evens $doubled") }
Kotlin lambdas use braces, and two conveniences change the feel from Dart. When a lambda is a function's last argument it moves outside the parentheses (the trailing-lambda form, so filter { … }), and a single-parameter lambda names it it implicitly rather than declaring (n). The collection methods themselves — filter for where, map as-is — are otherwise the same.
Extension functions (both have them)
extension NumberWords on int { String get spelledOut => this == 1 ? 'one' : 'many'; } void main() { print(3.spelledOut); // 'many' }
val Int.spelledOut: String get() = if (this == 1) "one" else "many" fun main() { println(3.spelledOut) // "many" }
A feature that survives the trip intact. Kotlin's extension functions and properties are the model Dart's extension methods were designed after, so 3.spelledOut works the same on both sides. Both are resolved statically (dispatched on the declared type, scoped to their import) rather than actually modifying the class — no monkey-patching in either language.
when & Sealed Classes
switch expression → when
String describe(int n) => switch (n) { 0 => 'zero', 1 || 2 || 3 => 'small', _ when n < 0 => 'negative', _ => 'large', }; void main() { print(describe(2)); print(describe(-5)); }
fun describe(n: Int): String = when { n == 0 -> "zero" n in 1..3 -> "small" n < 0 -> "negative" else -> "large" } fun main() { println(describe(2)) println(describe(-5)) }
Dart's switch expression is Kotlin's when, and both are true expressions that return a value. The forms differ: Kotlin uses -> arrows and else (not _), a subject-less when { cond -> … } acts like an if/else chain, and ranges (in 1..3) replace Dart's || alternatives and guards.
sealed classes & exhaustiveness
sealed class Shape {} class Circle extends Shape { final double r; Circle(this.r); } class Square extends Shape { final double side; Square(this.side); } double area(Shape shape) => switch (shape) { Circle(:final r) => 3.14159 * r * r, Square(:final side) => side * side, }; void main() => print(area(Circle(2)));
sealed class Shape data class Circle(val r: Double) : Shape() data class Square(val side: Double) : Shape() fun area(shape: Shape): Double = when (shape) { is Circle -> 3.14159 * shape.r * shape.r is Square -> shape.side * shape.side } fun main() = println(area(Circle(2.0)))
Sealed hierarchies plus exhaustive matching exist in both, and a when over a sealed type needs no else — omit a case and it fails to compile, exactly like Dart. The gap is in the matching: Kotlin's is Circle smart-casts the whole value (you then read shape.r), but it cannot destructure fields inline the way Dart's Circle(:final r) object pattern does.
Enums with behavior
enum Planet { earth(9.8), mars(3.7); final double gravity; const Planet(this.gravity); double weight(double mass) => mass * gravity; } void main() { print(Planet.mars.weight(70)); }
enum class Planet(val gravity: Double) { EARTH(9.8), MARS(3.7); fun weight(mass: Double) = mass * gravity } fun main() { println(Planet.MARS.weight(70.0)) }
Dart's enhanced enums — constructor parameters plus methods — map almost line for line to Kotlin's enum class, down to the semicolon separating the constant list from the members. The conventional difference is casing: Kotlin enum constants are UPPER_SNAKE_CASE, where Dart uses lowerCamelCase. Both expose the constants via values()/entries.
Pairs & Destructuring
Records → Pair / data class
(int, String) minWithLabel(List<int> numbers) { final sorted = [...numbers]..sort(); return (sorted.first, 'sorted'); } void main() { final (smallest, note) = minWithLabel([5, 2, 9]); print('$smallest / $note'); }
fun minWithLabel(numbers: List<Int>): Pair<Int, String> { val sorted = numbers.sorted() return sorted.first() to "sorted" // 'to' builds a Pair } fun main() { val (smallest, note) = minWithLabel(listOf(5, 2, 9)) println("$smallest / $note") }
Kotlin has no first-class record type. A two- or three-value return uses the built-in Pair or Triple (with the a to b infix builder), and anything richer — or anything with named fields, since Pair only offers first/second — should be a small data class. Destructuring on the left works because those types provide componentN operators.
Destructuring is positional
void main() { final point = (x: 3, y: 4); // named record final (:x, :y) = point; // destructure BY NAME print('$x $y'); }
data class Point(val x: Int, val y: Int) fun main() { val point = Point(3, 4) val (x, y) = point // destructure BY POSITION println("$x $y") }
A trap worth flagging: Kotlin destructuring is strictly positional. val (x, y) = point binds the first componentN to x and the second to y regardless of the property names, so reordering the variables silently swaps the values. Dart's named-record pattern (:x, :y) matches by field name and cannot be transposed by mistake.
Read-only vs Mutable
List vs MutableList
Dart has one List type and enforces immutability at run time (via const or List.unmodifiable). Kotlin splits the distinction into the type: List is read-only, MutableList can grow.
void main() { final fixed = const ['a', 'b']; // immutable at run time // fixed.add('c'); // throws: unsupported final growable = <String>['a']; growable.add('b'); print('$fixed $growable'); }
fun main() { val fixed = listOf("a", "b") // List: no add() in its API // fixed.add("c") // won't compile val growable = mutableListOf("a") // MutableList growable.add("b") println("$fixed $growable") }
Kotlin encodes read-only versus mutable in the static type: listOf returns a List whose interface simply has no add, while mutableListOf returns a MutableList. This is a compile-time contract, not a runtime freeze — the underlying object may still be mutable through another reference — so it is closer to TypeScript's readonly than to Dart's enforced unmodifiable. The same split gives Map/MutableMap and Set/MutableSet.
Maps & the to builder
void main() { final ages = {'Ada': 36, 'Grace': 45}; final withAlan = {...ages, 'Alan': 41}; print(withAlan['Ada']); withAlan.forEach((name, age) => print('$name: $age')); }
fun main() { val ages = mapOf("Ada" to 36, "Grace" to 45) val withAlan = ages + ("Alan" to 41) println(withAlan["Ada"]) withAlan.forEach { (name, age) -> println("$name: $age") } }
A Kotlin map literal is mapOf("Ada" to 36) using the same to infix that builds a Pair, and indexing with [] returns a nullable value (the absent-key case) just like Dart. Adding an entry to a read-only map with + produces a new map; for in-place mutation you would start from mutableMapOf and assign map["Alan"] = 41.
fold & reduce
void main() { final numbers = [1, 2, 3, 4]; final total = numbers.fold<int>(0, (sum, each) => sum + each); final maxValue = numbers.reduce((a, b) => a > b ? a : b); print('$total $maxValue'); }
fun main() { val numbers = listOf(1, 2, 3, 4) val total = numbers.fold(0) { sum, each -> sum + each } val maxValue = numbers.reduce { a, b -> if (a > b) a else b } println("$total $maxValue") }
The same two folds you know from Dart, with the same distinction: fold takes a seed of any type, while reduce is seedless and stays in the element type (and throws on an empty list). Since each is the last argument, both use the trailing-lambda form. Kotlin also ships specialized shortcuts — sum(), maxOrNull() — that you would usually prefer over a hand-written reduce.
Iteration & Sequences
for loops & ranges
void main() { for (final color in ['red', 'green', 'blue']) { print(color); } for (var i = 0; i < 3; i++) { print(i); } }
fun main() { for (color in listOf("red", "green", "blue")) { println(color) } for (i in 0 until 3) { // ranges replace the C-style for println(i) } }
The value-iterating for (x in collection) is identical. What Kotlin lacks is the C-style three-part for; counting loops use ranges instead — 0 until 3 (exclusive), 0..2 (inclusive), 3 downTo 1, and step 2. For an index alongside the value, for ((index, value) in list.withIndex()) is the idiom.
Iterable laziness → Sequence
void main() { // A Dart Iterable chain is lazy; take(3) does minimal work. final firstThree = Iterable<int> .generate(1000000, (i) => i) .map((i) => i * i) .take(3); print(firstThree.toList()); }
fun main() { // A plain List chain is EAGER; wrap in a Sequence for laziness. val firstThree = generateSequence(0) { it + 1 } // infinite, lazy .map { it * it } .take(3) .toList() println(firstThree) }
Here Kotlin inverts a Dart default. Dart's Iterable is lazy, so map/take chains defer work automatically. Kotlin's List operations are eager — each map allocates a full list — so laziness is opt-in via Sequence (asSequence(), or generateSequence for infinite streams). A Sequence is the true analog of a Dart Iterable.
sync* → sequence { yield }
Iterable<int> countdown(int from) sync* { for (var n = from; n > 0; n--) { yield n; } } void main() { print(countdown(3).toList()); // [3, 2, 1] }
fun countdown(from: Int): Sequence<Int> = sequence { for (n in from downTo 1) { yield(n) } } fun main() { println(countdown(3).toList()) // [3, 2, 1] }
Dart's sync*/yield generator becomes Kotlin's sequence { … } builder, where yield(n) is a function call rather than a keyword. It produces a lazy Sequence, so it is the same on-demand generation. Dart's asynchronous async*/Stream counterpart is Kotlin's flow { emit(…) }, covered in the concurrency section.
Coroutines & Real Threads
Future/async → suspend/coroutine
Future<int> fetchCount() async { await Future.delayed(Duration(milliseconds: 10)); return 42; } void main() async { final count = await fetchCount(); print(count); }
import kotlinx.coroutines.* suspend fun fetchCount(): Int { delay(10) return 42 } fun main() = runBlocking { val count = fetchCount() // no 'await' keyword — calling a suspend fun IS the await println(count) }
Dart's async function returning a Future<T> becomes a Kotlin suspend function returning a plain T. The key mental shift: there is no await keyword — simply calling a suspend function suspends until it completes. Suspend functions can only be called from a coroutine, so a top-level entry point opens one with runBlocking { … }.
Future.wait → async / awaitAll
Future<int> load(int id) async => id * 10; void main() async { final results = await Future.wait([load(1), load(2), load(3)]); print(results); // [10, 20, 30] }
import kotlinx.coroutines.* suspend fun load(id: Int): Int = id * 10 fun main() = runBlocking { val results = listOf(1, 2, 3) .map { async { load(it) } } // launch each concurrently .awaitAll() // then await them together println(results) // [10, 20, 30] }
To run several operations concurrently, wrap each in async { … } — which returns a Deferred<T>, Kotlin's closest thing to an eager Dart Future — and collect them with awaitAll(). That pairing is the equivalent of Dart's Future.wait. The async builder runs inside the structured scope of runBlocking, so a failure or cancellation propagates to its siblings.
Stream → Flow
Stream<int> ticks(int count) async* { for (var i = 1; i <= count; i++) { yield i; } } void main() async { await for (final tick in ticks(3)) { print(tick); } }
import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun ticks(count: Int): Flow<Int> = flow { for (i in 1..count) { emit(i) } } fun main() = runBlocking { ticks(3).collect { tick -> println(tick) } }
Dart's Stream<T> with async*/yield/await for maps onto Kotlin's Flow<T>: a flow { … } builder that emits values, consumed with collect. A Flow is cold (it starts on collection, like a fresh Dart stream subscription) and comes with the rich operator set — map, filter, debounce — that on the Dart side you reach to StreamTransformer or RxDart for.
Isolates → real threads (shared memory!)
The deepest concurrency difference. Dart isolates share no memory and pass copies; Kotlin coroutines can dispatch across a real thread pool that shares one heap — so data races are genuinely possible, and this example must guard its shared counter.
import 'dart:isolate'; Future<int> heavySum(int n) { // Runs on its own isolate: separate heap, arguments copied, no sharing. return Isolate.run(() { var total = 0; for (var i = 1; i <= n; i++) total += i; return total; }); } void main() async { print(await heavySum(1000)); // 500500 }
import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock fun main() = runBlocking { var total = 0 val lock = Mutex() // shared state needs protection (1..1000).map { i -> launch(Dispatchers.Default) { // real threads, one shared heap lock.withLock { total += i } } }.joinAll() println(total) // 500500 }
On the Dart side Isolate.run ships work to an isolate with its own heap — nothing is shared, so there is nothing to race. On the Kotlin side Dispatchers.Default runs coroutines on a real multi-thread pool over one shared heap, so the unsynchronized total += i would be a data race; the Mutex is doing load-bearing work. A Dart developer must relearn that shared mutable state across threads is now their responsibility to guard.
Generics & reified
Generic functions & bounds
T firstOr<T>(List<T> items, T fallback) => items.isEmpty ? fallback : items.first; num largest<T extends num>(List<T> values) => values.reduce((a, b) => a > b ? a : b); void main() { print(firstOr<int>([], -1)); print(largest<int>([3, 9, 2])); }
fun <T> firstOr(items: List<T>, fallback: T): T = if (items.isEmpty()) fallback else items.first() fun <T : Comparable<T>> largest(values: List<T>): T = values.reduce { a, b -> if (a > b) a else b } fun main() { println(firstOr(listOf<Int>(), -1)) println(largest(listOf(3, 9, 2))) }
Generic syntax differs mainly in placement: Kotlin puts the type parameter before the function name, fun <T> firstOr(…), and bounds it with : instead of extends — here T : Comparable<T> so > works. Inference is strong enough that the explicit <Int> is usually unnecessary, as on the right.
reified — inspect a type parameter
void main() { // Dart generics are ALWAYS reified: a type argument is available at run time. final items = <Object>[1, 'two', 3, 'four']; final ints = items.whereType<int>().toList(); print(ints); // [1, 3] }
inline fun <reified T> filterByType(items: List<Any>): List<T> = items.filterIsInstance<T>() fun main() { val items = listOf<Any>(1, "two", 3, "four") println(filterByType<Int>(items)) // [1, 3] }
Kotlin runs on the JVM, which erases generics, so by default a function cannot inspect its type parameter T at run time — the opposite of Dart, where every generic is reified. Kotlin buys the runtime type back with inline fun <reified T>: the compiler inlines the function and substitutes the concrete type, enabling filterIsInstance<T>() and is T. It is an opt-in on individual inline functions, not the language-wide guarantee Dart gives.
Packages & Tooling
Libraries → packages & imports
// geometry.dart // double areaOfCircle(double r) => 3.14159 * r * r; // // main.dart import 'geometry.dart'; void main() { print(areaOfCircle(2)); }
// geometry.kt // package geometry // fun areaOfCircle(r: Double) = 3.14159 * r * r // // main.kt import geometry.areaOfCircle fun main() { println(areaOfCircle(2.0)) }
Kotlin organizes code by package declarations and imports symbols by their fully-qualified name (import geometry.areaOfCircle), rather than Dart's import-by-file-path. Visibility defaults also differ: a top-level Kotlin declaration is public unless marked private/internal, whereas Dart makes anything prefixed with _ library-private. This example spans two files, so it is display-only.
pub → Gradle
// pubspec.yaml // dependencies: // http: ^1.2.0 // // Then: dart pub get // Then: import 'package:http/http.dart' as http; void main() { print('Dependencies are declared in pubspec.yaml and fetched by pub.'); }
// build.gradle.kts // dependencies { // implementation("io.ktor:ktor-client-core:2.3.0") // } // // Then: ./gradlew build fun main() { println("Dependencies live in build.gradle.kts and are fetched by Gradle.") }
Dart's pubspec.yaml plus dart pub get corresponds to a Gradle build script (build.gradle.kts, itself written in Kotlin) plus ./gradlew. The ecosystem is heavier than pub — Gradle handles multi-module builds, Android packaging, and multiplatform targets — and dependencies come from Maven Central rather than pub.dev.