Basics & Syntax
Hello, World
fun main() {
println("Hello, World!")
} void main() {
print('Hello, World!');
} Almost the same program. Three small things to retrain: the entry point is
void main() rather than fun main(), semicolons are mandatory, and the house indent is two spaces rather than four. Single quotes are the idiomatic Dart string — double quotes work identically, and the linter will nudge you toward single.val and var become final and var
The two mutability keywords survive the trip, but the names shift and Dart adds a third level that Kotlin only half has.
const val MAX_RETRIES = 3 // compile-time constant (primitives/String only)
fun main() {
val fixed = 1 // immutable
var counter = 0 // mutable
counter += 1
val explicit: Long = 42
println("$fixed $counter $explicit $MAX_RETRIES")
} const maxRetries = 3; // compile-time constant — and it works on OBJECTS too
void main() {
final fixed = 1; // immutable, but computed at RUN time
var counter = 0; // mutable
counter += 1;
int explicit = 42; // an explicit type, where you want one
// The distinction final/const does not have in Kotlin:
final now = DateTime.now(); // fine — final only means "assigned once"
// const now = DateTime.now(); // will not compile: not a compile-time value
const frozen = [1, 2, 3]; // a deeply immutable, canonicalized list
print('$fixed $counter $explicit $maxRetries $frozen ${now.isUtc}');
} final is Kotlin's val exactly: assigned once, at run time. const is the one that has no real Kotlin counterpart — Kotlin's const val is limited to primitives and strings, while Dart's const builds whole objects at compile time, deeply immutable and canonicalized (two identical const values are the same object). That sounds academic until you reach Flutter, where const widgets are the single biggest rebuild optimization there is — the framework can skip a subtree entirely because it knows the object is identical. Note also the naming: Dart constants are lowerCamelCase, not SCREAMING_CASE.String interpolation, unchanged
fun main() {
val name = "Ada"
val age = 36
println("$name is $age")
println("Next year: ${age + 1}")
val letter = """
Dear $name,
Regards
""".trimIndent()
println(letter)
} void main() {
final name = 'Ada';
final age = 36;
print('$name is $age'); // the same $ syntax
print('Next year: ${age + 1}'); // and the same ${} for expressions
// Multi-line strings use triple quotes too — but there is no trimIndent(),
// so the leading whitespace is part of the string. Left-align it, or trim.
final letter = '''
Dear $name,
Regards''';
print(letter);
print('adjacent ' 'literals ' 'concatenate'); // no + needed
} This one is free: same
$name, same ${expression}, same triple-quoted multi-line strings. The one thing missing is trimIndent() — Dart keeps every leading space in a multi-line literal, so either left-align the content against the margin (ugly, and what most code does) or trim it yourself. Adjacent string literals concatenate automatically, which is a small C-inherited nicety Kotlin does not have.Null Safety
Sound null safety, with the same operators
Dart added null safety in 2021 and borrowed the syntax more or less wholesale. Almost everything you know transfers.
fun findName(id: Int): String? = if (id == 1) "Ada" else null
fun main() {
val found: String? = findName(1)
// println(found.length) ← will not compile
println(found?.length) // safe call
println(findName(99)?.length ?: -1) // Elvis
println(found!!.uppercase()) // assert non-null
if (found != null) {
println(found.length) // smart cast
}
} String? findName(int id) => id == 1 ? 'Ada' : null;
void main() {
final String? found = findName(1);
// print(found.length); ← will not compile: found may be null
print(found?.length); // safe call — the same ?.
print(findName(99)?.length ?? -1); // ?? is the Elvis operator
print(found!.toUpperCase()); // ! is Kotlin's !! (one bang, not two)
if (found != null) {
print(found.length); // promotion — Kotlin's smart cast
}
int? counter;
counter ??= 5; // assign only if null
print(counter);
} The translation is nearly one-to-one:
?. is unchanged, ?: becomes ??, !! becomes a single !, and smart casts are called "type promotion" but behave the same. Dart's null safety is sound, meaning the compiler can prove a non-nullable variable is never null and the runtime can then optimize on that basis — and unlike Kotlin, there is no platform-type hole, because there is no Java interop to punch one. The bonus operator is ??=, which assigns only when the target is null.lateinit becomes late — and does more
class Screen {
lateinit var title: String // non-null, assigned later
// lateinit var count: Int ← not allowed: primitives are excluded
val expensive: String by lazy { // computed on first access
println("computing...")
"cached"
}
fun setUp() { title = "Home" }
}
fun main() {
val screen = Screen()
screen.setUp()
println(screen.title)
println(screen.expensive)
println(screen.expensive) // the block ran only once
} class Screen {
late String title; // non-null, assigned later — throws if read too early
late int count = 0; // works on ANY type, including int
// A late field WITH an initializer is lazy: the expression runs on first read.
late final String expensive = _compute();
String _compute() {
print('computing...');
return 'cached';
}
void setUp() => title = 'Home';
}
void main() {
final screen = Screen();
screen.setUp();
print(screen.title);
print(screen.expensive);
print(screen.expensive); // the initializer ran only once
print(screen.count);
} late is lateinit without the restrictions — it works on int, double, bool, and any other type, where lateinit excludes primitives. It also absorbs a second Kotlin feature: a late final field with an initializer is lazy, so it replaces by lazy { } as well. Both share the same hazard — reading before assignment is a runtime error (LateInitializationError), not a compile error, so late is you telling the compiler to trust you.let, also and the null toolkit
fun main() {
val name: String? = "Ada"
name?.let { println("hello ${it.uppercase()}") }
val names = listOf("Ada", null, "Grace")
println(names.filterNotNull())
val lengths = names.mapNotNull { it?.length }
println(lengths)
} void main() {
final String? name = 'Ada';
// There is no ?.let { }. A null check is the idiom, and promotion makes the
// body see a non-nullable String.
if (name != null) {
print('hello ${name.toUpperCase()}');
}
final names = <String?>['Ada', null, 'Grace'];
print(names.whereType<String>().toList()); // filterNotNull
final lengths = names.map((name) => name?.length).whereType<int>().toList();
print(lengths);
} Dart has no scope functions — no
let, apply, also, run, or with — because it has no lambdas with receivers to build them from. In practice that costs less than it does in Java: type promotion means a plain if (name != null) block sees a non-nullable value, which is what ?.let was mostly buying you. For collections, whereType<String>() is filterNotNull(), and mapNotNull decomposes into a map followed by a whereType.Functions & Parameters
Expression bodies use =>
fun twice(number: Int) = number * 2 // return type inferred
fun greet(name: String): String {
return "Hi, $name"
}
fun main() {
println(twice(21))
println(greet("Ada"))
} int twice(int number) => number * 2; // "fat arrow" — one expression, implicit return
// The return type comes FIRST, and it is not inferred on a top-level function.
String greet(String name) {
return 'Hi, $name';
}
void main() {
print(twice(21));
print(greet('Ada'));
} The
=> body is Kotlin's = expression under a different sigil, and it carries an implicit return just the same. Two things move: the return type goes to the front (C-style, not : Int at the end), and it is not inferred for a named function — you write int or void or String every time. Inference is reserved for locals and for closures.Named parameters must be declared as named
Kotlin lets any argument be passed by name. Dart makes it part of the signature — and this is the single most Flutter-shaped feature in the language, because every widget constructor is built from it.
// Any parameter may be named at the call site. Defaults are just defaults.
fun createButton(
label: String,
enabled: Boolean = true,
width: Int = 100,
) = "$label enabled=$enabled width=$width"
fun main() {
println(createButton("Save"))
println(createButton("Save", width = 200))
println(createButton(label = "Cancel", enabled = false))
} // Curly braces declare NAMED parameters. They are optional by default, so a
// non-nullable one needs either a default or the 'required' keyword.
String createButton(
String label, { // positional — cannot be passed by name
bool enabled = true,
int width = 100,
required String tooltip, // named AND mandatory
}) =>
'$label enabled=$enabled width=$width tooltip=$tooltip';
// Square brackets declare OPTIONAL POSITIONAL parameters — no Kotlin equivalent.
String tag(String name, [String? suffix]) => suffix == null ? name : '$name-$suffix';
void main() {
print(createButton('Save', tooltip: 'Save the file'));
print(createButton('Save', width: 200, tooltip: 'Save'));
// print(createButton(label: 'Cancel')); ← will not compile: label is positional
print(tag('button'));
print(tag('button', 'primary'));
} The call site looks familiar but the rules are stricter: a parameter is nameable only if it was declared inside
{ }, and a positional parameter can never be passed by name. Named parameters are optional unless marked required, which is the keyword you will type more than any other in Flutter — Text('hi', style: …), Padding(padding: …, child: …), and every other widget constructor is a wall of named arguments. The third form, optional positional in [ ], has no Kotlin counterpart at all.There are no varargs
fun total(vararg numbers: Int): Int = numbers.sum()
fun main() {
println(total(1, 2, 3))
val existing = intArrayOf(4, 5, 6)
println(total(*existing)) // spread
} // Dart has no vararg. You take a List, and the caller writes the brackets.
int total(List<int> numbers) => numbers.fold(0, (sum, number) => sum + number);
void main() {
print(total([1, 2, 3])); // the [ ] is the price
final existing = [4, 5, 6];
print(total(existing)); // but passing an existing list needs no spread
print(total([...existing, 7])); // ... is the spread operator, inside a literal
} No
vararg, no exceptions — the caller writes a list literal. The cost is two brackets per call; the compensation is the spread operator ..., which works inside any collection literal (not just at a call site) and is far more useful than Kotlin's *. There is also a null-aware spread, ...?, which contributes nothing if the operand is null — an idiom you will use constantly when building Flutter child lists.Collections
filter/map become where/map — and there is no it
fun main() {
val names = listOf("Ada", "Grace", "Alan", "Barbara")
println(names.filter { it.length > 3 }.map { it.uppercase() })
println(names.sumOf { it.length })
println(names.joinToString(", "))
println(names.groupBy { it.length })
println(names.firstOrNull { it.startsWith("Z") })
} void main() {
final names = ['Ada', 'Grace', 'Alan', 'Barbara'];
// 'where' is 'filter'. Every lambda names its parameter — there is no 'it'.
print(names.where((name) => name.length > 3).map((name) => name.toUpperCase()).toList());
// fold needs its type argument spelled out: without <int>, the accumulator
// infers as Object? and 'sum + ...' will not compile.
print(names.fold<int>(0, (sum, name) => sum + name.length)); // sumOf
print(names.join(', ')); // joinToString
// groupBy lives in package:collection, not the core library.
final byLength = <int, List<String>>{};
for (final name in names) {
byLength.putIfAbsent(name.length, () => []).add(name);
}
print(byLength);
// firstOrNull → firstWhere with an orElse, or the collection package.
print(names.where((name) => name.startsWith('Z')).firstOrNull);
} The vocabulary shifts —
filter is where, joinToString is join, sumOf is a fold — and the ergonomics take three real hits. There is no it, so every closure names its parameter. The core library is much thinner than kotlin.collections: no groupBy, no associateBy, no chunked, no zip — those live in package:collection, a first-party package you add to pubspec.yaml, and every real Dart project does. And fold needs its type argument written out: leave off the <int> and the accumulator infers as Object?, which fails to compile on the very next line with an error that points at the + rather than at the missing annotation.Collection-if and collection-for: build lists declaratively
This is Dart's best idea, and it exists because of Flutter: you can put an
if or a for inside a collection literal.fun main() {
val isAdmin = true
val extras = listOf("logs", "metrics")
// Kotlin builds the list with buildList, or with a chain of operations.
val menu = buildList {
add("home")
add("profile")
if (isAdmin) add("settings")
addAll(extras.map { "extra:$it" })
}
println(menu)
} void main() {
final isAdmin = true;
final extras = ['logs', 'metrics'];
final List<String>? maybeMore = null;
// The if and the for live INSIDE the literal. No builder, no temp variable.
final menu = [
'home',
'profile',
if (isAdmin) 'settings',
for (final extra in extras) 'extra:$extra',
...?maybeMore, // null-aware spread: contributes nothing
];
print(menu);
} Kotlin's nearest equivalent is
buildList { }, which is a lambda with a receiver and a pile of add calls; Dart makes conditional and repeated elements part of the literal syntax itself. It reads better, and in Flutter it is essential: a widget's children: list is written exactly this way — if (isLoading) Spinner(), for (final item in items) ItemTile(item) — so the whole UI tree stays one declarative expression rather than a procedural build-up.Sequences: Iterable is already lazy
fun main() {
// Without asSequence(), every step would build a whole intermediate list.
val firstTwo = listOf(1, 2, 3, 4, 5)
.asSequence()
.onEach { println("seeing $it") }
.map { it * it }
.take(2)
.toList()
println(firstTwo)
println(generateSequence(1) { it * 2 }.take(6).toList())
} void main() {
// map/where return a LAZY Iterable. Nothing runs until something iterates it —
// which is why the .toList() at the end is not optional.
final firstTwo = [1, 2, 3, 4, 5]
.map((number) {
print('seeing $number');
return number * number;
})
.take(2)
.toList();
print(firstTwo);
// Infinite sequences are fine, for the same reason.
print(Iterable.generate(6, (index) => 1 << index).toList());
} Dart's
Iterable is Kotlin's Sequence: map and where are lazy, and the trace above interleaves with the mapping and stops after two elements. There is no eager mode, so asSequence() has no counterpart — and the flip side is the trap: forget the trailing .toList() and you are holding a recipe rather than a result, which will re-run its closure every single time you iterate it. A lazy Iterable that reads a file or hits a counter will surprise you.Classes & Data
Constructors: initializing formals and named constructors
class Point(val x: Int, val y: Int) {
// A secondary constructor.
constructor(both: Int) : this(both, both)
override fun toString() = "Point($x, $y)"
}
fun main() {
println(Point(1, 2))
println(Point(3))
} class Point {
final int x;
final int y;
// "Initializing formals": this.x assigns the field directly — the closest
// thing Dart has to a primary constructor.
const Point(this.x, this.y);
// A NAMED constructor. Kotlin has no equivalent: you would overload, or add
// a factory in a companion object.
const Point.square(int size) : this(size, size);
// A factory constructor may return a cached or subclass instance.
factory Point.origin() => const Point(0, 0);
@override
String toString() => 'Point($x, $y)';
}
void main() {
print(const Point(1, 2));
print(const Point.square(3));
print(Point.origin());
print(identical(Point.origin(), const Point(0, 0))); // true — const is canonicalized
} There is no primary constructor, so fields are declared and then filled by initializing formals (
this.x in the parameter list) — more typing than Kotlin's header, less than Java's. What you gain is two things Kotlin lacks: named constructors (Point.square(3), rather than an overload or a companion factory), and const constructors, which build the object at compile time and canonicalize it — note that the two Point.origin() values above are literally the same object. That last property is what makes const widgets free to rebuild in Flutter.There is no data class
This is the loss you will feel most, and there is no keyword to fix it. Dart classes use identity equality by default, exactly like Java.
data class Person(val name: String, val age: Int)
fun main() {
val ada = Person("Ada", 36)
println(ada) // Person(name=Ada, age=36)
println(ada == Person("Ada", 36)) // true — generated equals
println(ada.copy(age = 37)) // generated copy
val people = setOf(ada, Person("Ada", 36))
println(people.size) // 1 — generated hashCode
} class Person {
final String name;
final int age;
const Person({required this.name, required this.age});
// Everything a 'data class' gives you, written out by hand:
@override
String toString() => 'Person(name: $name, age: $age)';
@override
bool operator ==(Object other) =>
other is Person && other.name == name && other.age == age;
@override
int get hashCode => Object.hash(name, age);
// copy() is called copyWith, and it is also hand-written.
Person copyWith({String? name, int? age}) =>
Person(name: name ?? this.name, age: age ?? this.age);
}
void main() {
const ada = Person(name: 'Ada', age: 36);
print(ada);
print(ada == const Person(name: 'Ada', age: 36)); // true — because we wrote ==
print(ada.copyWith(age: 37));
final people = {ada, const Person(name: 'Ada', age: 36)};
print(people.length); // 1 — because we wrote hashCode
} Miss the
== override and every comparison silently falls back to identity; miss hashCode and your objects behave correctly in a list but duplicate themselves in a Set or a Map key. This is why essentially every serious Flutter codebase runs a code generator — freezed is the standard one, and build_runner regenerates the ==, hashCode, toString and copyWith you would otherwise hand-write. Note the shape of copyWith: it works by taking every field as a nullable named parameter and falling back with ??, which means it cannot distinguish "leave it alone" from "set it to null" — a real limitation Kotlin's copy() does not have.Records: value equality without a class
Dart 3 added records — anonymous, structurally typed, value-equal tuples. They are not a replacement for data classes, but they cover the cases where Kotlin would have you write a
Pair, a Triple, or a throwaway class.// Kotlin returns a Pair, or a data class if the fields deserve names.
fun minMax(numbers: List<Int>): Pair<Int, Int> =
Pair(numbers.min(), numbers.max())
fun main() {
val (low, high) = minMax(listOf(3, 1, 4, 1, 5))
println("$low..$high")
println(Pair(1, "a") == Pair(1, "a")) // true — Pair is a data class
} // A record type with NAMED fields. No class declaration anywhere.
({int low, int high}) minMax(List<int> numbers) {
var low = numbers.first;
var high = numbers.first;
for (final number in numbers) {
if (number < low) low = number;
if (number > high) high = number;
}
return (low: low, high: high);
}
void main() {
final range = minMax([3, 1, 4, 1, 5]);
print('${range.low}..${range.high}'); // fields by name, and type-checked
// Destructuring, via a pattern.
final (low: lowest, high: highest) = minMax([3, 1, 4]);
print('$lowest..$highest');
// Records have value equality for free — the thing classes do not.
print((1, 'a') == (1, 'a')); // true
} A record is a value: structurally typed, equal by content, destructurable, and free of any declaration. That makes it strictly better than
Pair/Triple — the fields can have names, so range.low beats range.first — and it is the right answer for multiple return values. What it is not is a replacement for a data class: a record has no name, no methods, no constructor validation, and no way to distinguish two records that happen to have the same shape. Use records for transient tuples, and hand-written (or generated) classes for anything with an identity in your domain.Visibility is an underscore
class Counter {
private var count = 0 // private to the class
internal val label = "counter" // visible within the module
fun increment(): Int {
count += 1
return count
}
}
fun main() {
val counter = Counter()
counter.increment()
println("${counter.label}: ${counter.increment()}")
} class Counter {
// No 'private' keyword. A leading underscore makes a member library-private:
// visible everywhere in this FILE, invisible outside it.
int _count = 0;
final String label = 'counter';
int increment() {
_count += 1;
return _count;
}
}
void main() {
final counter = Counter();
counter.increment();
print('${counter.label}: ${counter.increment()}');
// counter._count is reachable HERE — same file — but not from another file.
} There are no visibility keywords at all: a leading underscore makes a declaration private to its library, which by default means its file. So
_count is not private to the class — anything else in the same file can reach it — and Dart has nothing corresponding to protected. The underscore is part of the name, so making a field private is a rename, and every reference changes with it. On the upside, the same mechanism works on top-level functions, classes, and variables, which is roughly Kotlin's internal.Sealed Types & Pattern Matching
sealed class and exhaustive switch
Dart 3 added sealed classes and pattern matching together, and the result is a near-exact match for Kotlin's
sealed + when — with destructuring on top.sealed interface Payment
data class Card(val number: String, val cents: Int) : Payment
data class Cash(val cents: Int) : Payment
// No else branch: the compiler knows the subtypes and checks exhaustiveness.
fun describe(payment: Payment): String = when (payment) {
is Card -> "card ending ${payment.number} for ${payment.cents}c"
is Cash -> "cash for ${payment.cents}c"
}
fun main() {
println(describe(Card("4242", 500)))
println(describe(Cash(200)))
} sealed class Payment {
const Payment();
}
class Card extends Payment {
final String number;
final int cents;
const Card(this.number, this.cents);
}
class Cash extends Payment {
final int cents;
const Cash(this.cents);
}
// A switch EXPRESSION. No default needed — sealed means the compiler can check
// exhaustiveness, and adding a third Payment breaks this at compile time.
String describe(Payment payment) => switch (payment) {
// Object patterns destructure as they match.
Card(number: final number, cents: final cents) =>
'card ending $number for ${cents}c',
Cash(cents: final cents) when cents > 100 => 'a lot of cash: ${cents}c',
Cash(cents: final cents) => 'cash for ${cents}c',
};
void main() {
print(describe(const Card('4242', 500)));
print(describe(const Cash(200)));
print(describe(const Cash(50)));
} The guarantee is the same one:
sealed tells the compiler the complete set of subtypes, so a switch that misses one stops compiling. Two differences favor Dart. Object patterns destructure while they match — Card(number: final number) binds the field in the same breath as the type test, where Kotlin's is Card smart-casts but still makes you write payment.number. And when guards attach to a case (note the reversal: Kotlin's when is the whole construct, Dart's when is the guard clause). Sealed subtypes must live in the same library — the same file, in practice — which matches how you would organize them anyway.Patterns everywhere, not just in switch
data class Point(val x: Int, val y: Int)
fun main() {
val point = Point(1, 2)
val (x, y) = point // componentN destructuring
println("$x $y")
val pairs = listOf(1 to "one", 2 to "two")
for ((number, name) in pairs) {
println("$number = $name")
}
// A list cannot be destructured positionally.
val numbers = listOf(10, 20, 30)
println("${numbers.first()} then ${numbers.drop(1)}")
} void main() {
// Records destructure, and so do lists and maps — anywhere a pattern is allowed.
final (x, y) = (1, 2);
print('$x $y');
for (final (number, name) in [(1, 'one'), (2, 'two')]) {
print('$number = $name');
}
// List patterns, with a rest element.
final [first, ...rest] = [10, 20, 30];
print('$first then $rest');
// Patterns work in if-case too, which is a null check and a bind in one.
final Object value = [1, 2, 3];
if (value case [final only, ...]) {
print('starts with $only');
}
// And a map pattern, for the JSON-shaped code you write constantly.
final json = {'name': 'Ada', 'age': 36};
if (json case {'name': final String name, 'age': final int age}) {
print('$name is $age');
}
} Kotlin's destructuring is a convention —
component1(), component2() — which is why it works on data classes and pairs but never on a List. Dart's is a real pattern language, so lists destructure with a rest element, maps destructure by key, and if (value case pattern) tests and binds in one step. The map pattern in particular is the one you will reach for daily: it is the difference between validating a decoded JSON blob field by field and matching its shape in a single expression.Enhanced enums
enum class Status(val label: String) {
ACTIVE("running"),
PAUSED("on hold");
fun describe() = "$name: $label"
}
fun main() {
for (status in Status.entries) {
println(status.describe())
}
println(Status.valueOf("ACTIVE").ordinal)
} enum Status {
active('running'),
paused('on hold');
const Status(this.label); // enhanced enums (Dart 2.17+) take constructors
final String label;
String describe() => '$name: $label';
}
void main() {
for (final status in Status.values) {
print(status.describe());
}
print(Status.values.byName('active').index);
// Enums are exhaustively switchable, like a sealed class.
final message = switch (Status.active) {
Status.active => 'go',
Status.paused => 'wait',
};
print(message);
} Enhanced enums carry constructors, fields and methods just as Kotlin's do, and the mapping is direct:
entries becomes values, valueOf becomes values.byName, and ordinal becomes index. The one difference in Dart's favor is that the constants are lowerCamelCase and each enum value is automatically a const, so it costs nothing to use one as a widget key or a map key. Enums are exhaustively switchable without a default, exactly like a sealed class.Extensions & Mixins
Extension methods, with a name
fun String.shout() = "${uppercase()}!"
val String.initials: String
get() = split(" ").map { it.first() }.joinToString("")
fun main() {
println("hello".shout())
println("Ada Lovelace".initials)
} // The extension itself is named, and the receiver is 'this' (usually implicit).
extension Shouting on String {
String shout() => '${toUpperCase()}!';
// Extension GETTERS work too — the equivalent of an extension property.
String get initials =>
split(' ').map((word) => word[0]).join();
}
void main() {
print('hello'.shout());
print('Ada Lovelace'.initials);
} Same feature, same static dispatch, same caveat that it cannot override a real method — the only visible change is that the extension block gets a name (
Shouting), which exists so you can resolve conflicts and hide or show it in an import. Extension getters cover Kotlin's extension properties. Dart also has extension type (3.3+), a zero-cost wrapper around an existing type, which is closer to Kotlin's value class than to anything in this example.Mixins are a real language feature
Kotlin composes behavior with interfaces that have default methods, or with
by delegation. Dart has actual mixins, with state.// An interface with a default implementation — but it cannot hold state.
interface Swimmer {
fun swim() = println("swimming")
}
interface Flyer {
fun fly() = println("flying")
}
class Duck : Swimmer, Flyer
fun main() {
val duck = Duck()
duck.swim()
duck.fly()
} // A mixin can hold STATE, which an interface default method cannot.
mixin Swimmer {
int strokes = 0;
void swim() {
strokes += 1;
print('swimming (stroke $strokes)');
}
}
// 'on' constrains a mixin to classes that extend Bird — so it may call Bird's API.
abstract class Bird {
String get name;
}
mixin Flyer on Bird {
void fly() => print('$name is flying');
}
class Duck extends Bird with Swimmer, Flyer {
@override
String get name => 'duck';
}
void main() {
final duck = Duck();
duck.swim();
duck.swim();
duck.fly();
} The differences that matter: a mixin can carry fields, where a Kotlin interface with a default method cannot hold state at all; and the
on clause constrains where a mixin may be applied, letting it call methods of the class it is mixed into. Multiple mixins are applied left to right and later ones win, which is a linearization rule you have to actually know. In Flutter you will meet this immediately — with SingleTickerProviderStateMixin on an animated widget's state class is the canonical example, and it works precisely because the mixin holds the ticker state for you.Async & Concurrency
suspend becomes async, and it is not the same thing
The syntax will feel like home. The execution model underneath is genuinely different, and this is the concept to slow down on.
import kotlinx.coroutines.*
suspend fun fetchUser(id: Int): String {
delay(10)
return "user-$id"
}
fun main() = runBlocking {
println(fetchUser(1))
// async starts work concurrently; await collects it.
val results = coroutineScope {
(1..3).map { id -> async { fetchUser(id) } }.awaitAll()
}
println(results)
} Future<String> fetchUser(int id) async {
await Future.delayed(const Duration(milliseconds: 10));
return 'user-$id';
}
void main() async {
print(await fetchUser(1));
// Calling an async function starts it immediately — there is no 'async { }'
// block, and no scope that owns the work.
final futures = [for (final id in [1, 2, 3]) fetchUser(id)];
print(await Future.wait(futures)); // awaitAll
} A
Future is Deferred and Future.wait is awaitAll, but three assumptions break. There is no dispatcher and no thread pool: Dart runs your code on a single event-loop thread, so await yields to the loop and nothing ever runs in parallel — Dispatchers.Default has no counterpart, and CPU-bound work will freeze the UI no matter how many futures you create. Calling an async function starts it at once, so there is no lazy async and no structured concurrency: nothing owns the work, and nothing cancels it. And a Future genuinely cannot be canceled — you can ignore its result, but the work runs to completion.Flow becomes Stream
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun ticks(): Flow<Int> = flow {
for (number in 1..3) {
delay(10)
emit(number)
}
}
fun main() = runBlocking {
ticks()
.map { it * 10 }
.collect { println("got $it") }
} // async* + yield is the flow { emit } builder.
Stream<int> ticks() async* {
for (var number = 1; number <= 3; number++) {
await Future.delayed(const Duration(milliseconds: 10));
yield number;
}
}
void main() async {
// 'await for' is collect — and it is a real loop, so break/return work in it.
await for (final value in ticks().map((number) => number * 10)) {
print('got $value');
}
} Stream is Flow: lazy, asynchronous, and built with async*/yield where Kotlin uses flow { emit() }. Consuming it with await for is nicer than collect { } — it is an ordinary loop, so break, continue and return all mean what they say, where a collect lambda cannot break out. The distinction to carry over is cold versus hot: a plain Stream is cold and single-subscription (listening twice throws), while a StreamController.broadcast() is the hot, multi-listener kind — roughly Kotlin's SharedFlow.Threads become isolates: no shared memory
Kotlin's coroutines run on real threads over shared memory, so parallelism is free and data races are your problem. Dart takes the opposite trade.
import kotlinx.coroutines.*
fun heavyWork(seed: Int): Int {
var total = 0
for (number in 1..1_000_000) total += (number + seed) % 7
return total
}
fun main() = runBlocking {
// Dispatchers.Default is a real thread pool: this runs in PARALLEL,
// and both coroutines can touch the same objects.
val results = withContext(Dispatchers.Default) {
(1..2).map { seed -> async { heavyWork(seed) } }.awaitAll()
}
println(results)
} import 'dart:isolate';
int heavyWork(int seed) {
var total = 0;
for (var number = 1; number <= 1000000; number++) {
total += (number + seed) % 7;
}
return total;
}
void main() async {
// Isolate.run moves the work to a separate isolate: its own memory, its own
// event loop. Arguments and results are COPIED across the boundary.
final results = await Future.wait([
Isolate.run(() => heavyWork(1)),
Isolate.run(() => heavyWork(2)),
]);
print(results);
} An isolate is a thread with no shared heap: it has its own memory and its own event loop, and everything crossing the boundary is copied (or transferred, for a
TransferableTypedData). So there are no data races, no @Volatile, no Mutex, and no synchronized — the entire category of bug is gone by construction, and so is the entire category of solution. The cost is real: you cannot hand an isolate a reference to your object graph, and the copy is O(size). The rule of thumb in Flutter is that async is for waiting (I/O, network) and Isolate.run is for computing — parsing a large JSON payload on the main isolate is what drops your frames.Errors & Exceptions
try/catch, with an on clause
class NotFoundException(val id: Int) : Exception("no such id: $id")
fun find(id: Int): String {
if (id != 1) throw NotFoundException(id)
return "Ada"
}
fun main() {
try {
println(find(1))
println(find(99))
} catch (error: NotFoundException) {
println("caught: ${error.message}")
} finally {
println("done")
}
val parsed = try { "nope".toInt() } catch (error: NumberFormatException) { -1 }
println(parsed)
println("nope".toIntOrNull() ?: -1)
} // Any object can be thrown. By convention, implement Exception.
class NotFoundException implements Exception {
final int id;
NotFoundException(this.id);
@override
String toString() => 'no such id: $id';
}
String find(int id) {
if (id != 1) throw NotFoundException(id);
return 'Ada';
}
void main() {
try {
print(find(1));
print(find(99));
} on NotFoundException catch (error) { // 'on' selects the type
print('caught: $error');
} catch (error, stackTrace) { // bare catch: anything at all
print('unexpected: $error ${stackTrace.toString().isNotEmpty}');
} finally {
print('done');
}
// try is a statement, so there is no try-expression. But the parse-or-null
// family exists, which is usually what you actually wanted.
print(int.tryParse('nope') ?? -1);
} The mechanics are close, with two wrinkles. Catching by type uses
on, and a bare catch takes an optional second parameter — the StackTrace — which is how you avoid losing it when rethrowing (use rethrow, not throw error). And like Kotlin, Dart has no checked exceptions; unlike Kotlin, it has no try-expression and no runCatching, so a fallback becomes a statement or a helper. int.tryParse is toIntOrNull, and the null-returning family is the idiomatic escape hatch.require becomes assert — and it disappears in release
class Rectangle(val width: Int, val height: Int) {
init {
require(width > 0) { "width must be positive" }
require(height > 0) { "height must be positive" }
}
}
fun main() {
println(Rectangle(3, 4).width)
try {
Rectangle(-1, 4)
} catch (error: IllegalArgumentException) {
println("rejected: ${error.message}")
}
} class Rectangle {
final int width;
final int height;
// assert in an initializer list. It runs in DEBUG only — in a release build
// it is compiled out entirely, and Rectangle(-1, 4) constructs happily.
Rectangle(this.width, this.height)
: assert(width > 0, 'width must be positive'),
assert(height > 0, 'height must be positive');
// For a check that must survive a release build, throw explicitly.
factory Rectangle.checked(int width, int height) {
if (width <= 0) throw ArgumentError.value(width, 'width', 'must be positive');
return Rectangle(width, height);
}
}
void main() {
print(Rectangle(3, 4).width);
try {
Rectangle.checked(-1, 4);
} on ArgumentError catch (error) {
print('rejected: ${error.message}');
}
} This is a genuine trap on the way over. Kotlin's
require throws in every build; Dart's assert is stripped from release builds, so a validation you wrote as an assert silently stops running the moment you ship. Use assert for invariants you are documenting to yourself and to the debug-mode framework (Flutter's own widgets are full of them), and throw an ArgumentError or StateError explicitly for anything a user's data could actually violate.Compose vs Flutter Widgets
A @Composable function becomes a Widget class
The heart of the migration. Both frameworks are declarative and rebuild from state, so the mental model survives intact — but Compose's unit of UI is an annotated function, and Flutter's is a class that returns a tree from
build.import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.unit.dp
@Composable
fun Greeting(name: String, onTap: () -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Hello, $name")
Spacer(Modifier.height(8.dp))
Button(onClick = onTap) {
Text("Tap me")
}
}
} import 'package:flutter/material.dart';
// A StatelessWidget is a @Composable with no remembered state. The constructor
// parameters are the function parameters, and they are 'final' — a widget is an
// immutable DESCRIPTION of UI, not the UI itself.
class Greeting extends StatelessWidget {
const Greeting({super.key, required this.name, required this.onTap});
final String name;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text('Hello, $name'),
const SizedBox(height: 8),
ElevatedButton(
onPressed: onTap,
child: const Text('Tap me'),
),
],
),
);
}
} Four things to internalize. A widget is an immutable description that Flutter throws away and rebuilds constantly — it is not the thing on screen, so building one is cheap and you should never try to hold or mutate it. Layout is composed by nesting rather than by a
Modifier chain: Modifier.padding(16.dp) becomes a Padding widget wrapped around the child, which is why Flutter trees are so deeply indented. A widget takes exactly one child or a list of children, and everything is a named argument. And const on a widget is not decoration — a const subtree is canonicalized, so Flutter can skip rebuilding it entirely.remember { mutableStateOf } becomes State + setState
import androidx.compose.material3.*
import androidx.compose.runtime.*
@Composable
fun Counter() {
// State survives recomposition; writing to it triggers one.
var count by remember { mutableStateOf(0) }
Button(onClick = { count += 1 }) {
Text("Tapped $count times")
}
} import 'package:flutter/material.dart';
// State lives in a SEPARATE object from the widget, because the widget itself is
// rebuilt and discarded on every frame — the State survives across rebuilds.
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int count = 0; // this field is the 'remember'
@override
Widget build(BuildContext context) {
return ElevatedButton(
// setState is the "this changed, rebuild me" signal. Mutating 'count'
// without it changes the value and shows you the OLD screen.
onPressed: () => setState(() => count += 1),
child: Text('Tapped $count times'),
);
}
} The two-class split is the price of the widget being immutable:
Counter is rebuilt from scratch each frame, so the mutable count has to live in _CounterState, which Flutter keeps alive across rebuilds. That State object is remember. The trap Compose does not have: setState is not automatic. Compose observes a MutableState and recomposes when you write to it, whereas Flutter only rebuilds when you explicitly say so — assign to count without wrapping it in setState and the field really does change while the screen keeps showing the old number, with no error anywhere. Beyond this, both ecosystems push you toward hoisting state out of the widget entirely (Riverpod or Bloc, against ViewModel and StateFlow).LazyColumn becomes ListView.builder
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.*
@Composable
fun NameList(names: List<String>, isLoading: Boolean) {
LazyColumn {
if (isLoading) {
item { CircularProgressIndicator() }
}
items(names) { name ->
ListTile(headlineContent = { Text(name) })
}
}
} import 'package:flutter/material.dart';
class NameList extends StatelessWidget {
const NameList({super.key, required this.names, required this.isLoading});
final List<String> names;
final bool isLoading;
@override
Widget build(BuildContext context) {
// The .builder constructor is the lazy one: it calls itemBuilder only for
// the rows actually on screen. A plain ListView(children: [...]) builds ALL
// of them — fine for ten rows, ruinous for ten thousand.
return ListView.builder(
itemCount: names.length + (isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (isLoading && index == 0) {
return const Center(child: CircularProgressIndicator());
}
final name = names[index - (isLoading ? 1 : 0)];
return ListTile(title: Text(name));
},
);
}
} Compose's
LazyColumn takes a DSL where item { } and items(list) { } are calls in a builder scope; Flutter has no lambda-with-receiver, so laziness is expressed as a constructor that takes an itemCount and an itemBuilder callback. Notice what that costs: the index arithmetic above is manual, because there is no way to declare heterogeneous sections. When the list is short and static, the children: + collection-if/for form from the Collections section reads far better; reach for .builder when the list is long or unbounded.LaunchedEffect becomes initState
import androidx.compose.material3.*
import androidx.compose.runtime.*
@Composable
fun UserScreen(userId: Int) {
var name by remember { mutableStateOf<String?>(null) }
// Re-runs whenever userId changes; canceled automatically if the composable
// leaves the tree, because it runs in a coroutine scope tied to it.
LaunchedEffect(userId) {
name = fetchUser(userId)
}
Text(name ?: "loading...")
}
suspend fun fetchUser(id: Int): String = "user-$id" import 'package:flutter/material.dart';
class UserScreen extends StatefulWidget {
const UserScreen({super.key, required this.userId});
final int userId;
@override
State<UserScreen> createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> {
String? name;
@override
void initState() { // runs ONCE, when the State is created
super.initState();
_load();
}
@override
void didUpdateWidget(UserScreen old) { // the "keys changed" half of LaunchedEffect
super.didUpdateWidget(old);
if (old.userId != widget.userId) _load();
}
Future<void> _load() async {
final loaded = await fetchUser(widget.userId);
if (!mounted) return; // the widget may be gone — check before setState
setState(() => name = loaded);
}
@override
void dispose() { // your only cleanup hook
super.dispose();
}
@override
Widget build(BuildContext context) => Text(name ?? 'loading...');
}
Future<String> fetchUser(int id) async => 'user-$id'; One Compose construct becomes three lifecycle overrides, and the difference is not just verbosity.
LaunchedEffect(userId) is keyed: it re-runs when the key changes and its coroutine is canceled when the composable leaves the tree, which is structured concurrency doing real work for you. Flutter has no such scope — initState fires once, didUpdateWidget is where you notice the key changed, and a Future cannot be canceled at all. Hence the if (!mounted) return; line, which is the single most important idiom in this example: without it, an async result arriving after the user has navigated away calls setState on a dead State and throws. Every Flutter developer has shipped that bug.