Hello World & Running
Hello, World
A Dart file has top-level functions, so
main sits at file scope. Java has no file scope at all — everything lives inside a class, including the entry point.void main() {
print("Hello, World!");
}class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}The braces, semicolons and general shape are close enough that a Dart programmer can read Java immediately, which is not an accident: both descend from the same C-family syntax and Dart was designed by people who had watched Java closely. The differences that follow are almost never syntactic — they are about what the type system promises, what the runtime provides, and how much ceremony a class costs.
Everything lives in a class
There is no file scope, so a free function becomes a
static method and a top-level constant becomes a static final field — which is why Java codebases are full of classes named SomethingUtils that hold nothing but statics.// Top-level functions and variables are ordinary Dart.
const int maximumRetries = 3;
int double(int value) => value * 2;
void main() {
print(double(21));
print(maximumRetries);
}class Main {
// A "top-level" function is a static method on some class, and a
// constant is a static final field on one.
static final int MAXIMUM_RETRIES = 3;
static int doubled(int value) { return value * 2; }
public static void main(String[] args) {
System.out.println(doubled(21));
System.out.println(MAXIMUM_RETRIES);
}
}Two naming conventions to absorb, because Java code that ignores them reads as foreign: constants are
SCREAMING_SNAKE_CASE, and there is no arrow-function shorthand for a method body — => exists in Java only for lambdas. Note also that double is a reserved type name in Java, which is why the method here is doubled; a Dart identifier that happens to be a Java primitive name will need renaming.Null Safety Goes Away
🚨 You are giving up sound null safety
🚨 This is the row that matters most, and it runs against the direction of most pages on this site: Dart is the safer language here.
String? and String are different types and the compiler proves the second is never null.String? findName(List<String> names, String prefix) {
for (final name in names) {
if (name.startsWith(prefix)) return name;
}
return null;
}
void main() {
final found = findName(["ada", "bob"], "z");
// found.length would not COMPILE: found is String?, not String.
print(found?.length ?? "nothing found");
String definitely = "widget";
print(definitely.length); // no check needed: it cannot be null
}import java.util.List;
class Main {
static String findName(List<String> names, String prefix) {
for (String name : names) {
if (name.startsWith(prefix)) return name;
}
return null; // and the signature does not say so
}
public static void main(String[] args) {
String found = findName(List.of("ada", "bob"), "z");
// found.length() compiles fine and throws at run time.
System.out.println(found == null ? "nothing found" : found.length());
String definitely = "widget";
System.out.println(definitely.length()); // could still be null
}
}Java has
null underneath every reference, no non-nullable type, and NullPointerException as its most common production failure. The partial recoveries: Optional<T> for return values (never for fields or parameters — that is the official guidance), Objects.requireNonNull at boundaries, nullability annotations that a static analyzer checks, and helpful NullPointerException messages since Java 15, which at least name the reference that was null. None of them is the guarantee you had. Plan for it: check at every boundary where Dart would have checked for you.late, final and const become one keyword
Dart's three-way distinction —
final (assign once at run time), const (compile time) and late (assign after construction, checked at first read) — collapses into Java's single final.class Configuration {
// Initialized after construction, checked at first read.
late final String endpoint;
// A compile-time constant.
static const int timeoutSeconds = 30;
void connect(String host) {
endpoint = "https://" + host;
}
}
void main() {
final configuration = Configuration();
configuration.connect("example.test");
print(configuration.endpoint);
print(Configuration.timeoutSeconds);
}class Configuration {
// final means assign-once, and the compiler enforces that it IS
// assigned before the constructor finishes — there is no "late".
private String endpoint;
// static final is the nearest thing to const; a genuine
// compile-time constant only when the value is a literal.
static final int TIMEOUT_SECONDS = 30;
void connect(String host) {
this.endpoint = "https://" + host;
}
String endpoint() { return endpoint; }
}
class Main {
public static void main(String[] args) {
Configuration configuration = new Configuration();
configuration.connect("example.test");
System.out.println(configuration.endpoint());
System.out.println(Configuration.TIMEOUT_SECONDS);
}
}late final is the one with no equivalent, and its absence is felt: a Java field that cannot be set in the constructor is simply a mutable field with a convention, and reading it before assignment gives you null rather than a clear error. The other loss is const constructors — Dart can build an entire object graph at compile time and share one canonical instance, which is a large part of why Flutter widget trees are cheap. Java has no equivalent and allocates.Types & Inference
var means the same thing
Java 10 added
var and it means what Dart's means: infer the type, then hold it fixed. The syntax lines up almost exactly here.void main() {
var count = 42; // int, inferred and fixed
final ratio = 0.5; // double, and cannot be reassigned
String label = "widget";
// count = "text"; // would not compile
print("$count $ratio $label");
}class Main {
public static void main(String[] args) {
var count = 42; // int, inferred and fixed (Java 10+)
final double ratio = 0.5;
String label = "widget";
// count = "text"; // would not compile
System.out.println(count + " " + ratio + " " + label);
}
}Two differences a Dart programmer will notice immediately. There is no string interpolation — no
$name, no ${expression} — so concatenation with + or String.format is the idiom, and Java 21 previewed string templates and then withdrew them. And var is local-variable-only: it cannot be used for a field, a parameter or a return type, where Dart allows it anywhere.Records, on both sides
Both languages added records, and they are not the same feature. Dart's are structural —
(String, int) is a type with no declaration — while Java's are nominal classes that must be declared.// Dart 3 records are structural: no declaration needed.
(String, int) parsePair(String text) {
final parts = text.split(":");
return (parts[0], int.parse(parts[1]));
}
void main() {
final (name, count) = parsePair("widget:7");
print("$name $count");
// Named fields, still with no type declaration:
final point = (x: 1, y: 2);
print("${point.x},${point.y}");
}record Pair(String name, int count) { }
class Main {
static Pair parsePair(String text) {
String[] parts = text.split(":");
return new Pair(parts[0], Integer.parseInt(parts[1]));
}
public static void main(String[] args) {
Pair pair = parsePair("widget:7");
System.out.println(pair.name() + " " + pair.count());
// Java records must be DECLARED. There is no anonymous
// structural record and no destructuring assignment outside
// a pattern match.
record Point(int x, int y) { }
Point point = new Point(1, 2);
System.out.println(point.x() + "," + point.y());
}
}The practical consequence is that returning two values costs a declaration in Java, so the pressure to declare a small named type is higher — which is often better design and is more typing. Java records do generate
equals, hashCode, toString and accessors, so they discharge the boilerplate; and Java 21 pattern matching does destructure them, but only inside a switch or instanceof, not in a plain assignment the way Dart's final (a, b) = … does.Classes & Constructors
Constructors lose their shorthand
Two Dart conveniences disappear:
this.width as a parameter, which assigns the field for you, and named constructors, which Java replaces with a static factory method.class Rectangle {
final int width;
final int height;
// The initializing formals do the assignment for you.
Rectangle(this.width, this.height);
// A named constructor, which Java has no equivalent of.
Rectangle.square(int side) : width = side, height = side;
int get area => width * height;
}
void main() {
print(Rectangle(3, 4).area);
print(Rectangle.square(5).area);
}class Rectangle {
private final int width;
private final int height;
Rectangle(int width, int height) {
this.width = width; // written out, every time
this.height = height;
}
// No named constructors. The conventional replacement is a
// static factory method with a descriptive name.
static Rectangle square(int side) {
return new Rectangle(side, side);
}
int area() { return width * height; }
}
class Main {
public static void main(String[] args) {
System.out.println(new Rectangle(3, 4).area());
System.out.println(Rectangle.square(5).area());
}
}The static factory is not merely a workaround — it is a well-regarded Java idiom, because the name documents the intent and the method may return a cached instance or a subclass. What genuinely costs you is the assignment boilerplate, which is why Java projects reach for Lombok or, better, records where the class is a value. Two more absences: no getter syntax (
int get area becomes a method with parentheses at the call site), and no new being optional — Java requires it.Named and optional parameters go away
🚨 Java has neither named arguments nor default parameter values, and for a Flutter developer this is the single largest day-to-day ergonomic loss — the widget constructors you write every day are named-argument constructors.
String describe(String name, {int count = 1, bool loud = false}) {
final text = "$count x $name";
return loud ? text.toUpperCase() : text;
}
void main() {
print(describe("widget"));
print(describe("widget", loud: true));
print(describe("widget", count: 3, loud: false));
}class Main {
// No named arguments and no default values. The options are
// overloading, or a builder for anything with several settings.
static String describe(String name) {
return describe(name, 1, false);
}
static String describe(String name, boolean loud) {
return describe(name, 1, loud);
}
static String describe(String name, int count, boolean loud) {
String text = count + " x " + name;
return loud ? text.toUpperCase() : text;
}
public static void main(String[] args) {
System.out.println(describe("widget"));
System.out.println(describe("widget", true));
System.out.println(describe("widget", 3, false));
}
}The three replacements, in the order Java code reaches for them: overloading for two or three variants (as here), a builder for anything with more (
Thing.builder().name("x").count(3).build()), and a parameter object — often a record — when the arguments belong together. A four-argument Java call site is genuinely hard to read, which is why the builder pattern is everywhere; that pattern exists to work around exactly what Dart gives you for free.Extension methods have no equivalent
Dart extensions add methods to types you did not write, resolved at compile time. Java has nothing of the kind — a helper is a static method, and the call site reads
shout(text) rather than text.shout().extension StringShout on String {
String shout() => toUpperCase() + "!";
}
extension IntTimes on int {
void times(void Function(int) action) {
for (var index = 0; index < this; index++) action(index);
}
}
void main() {
print("hello".shout());
3.times((index) => print(index));
}class Main {
// No extensions. A helper is a static method taking the value as
// its first argument, so the call reads inside-out.
static String shout(String text) {
return text.toUpperCase() + "!";
}
public static void main(String[] args) {
System.out.println(shout("hello"));
for (int index = 0; index < 3; index++) {
System.out.println(index);
}
}
}The loss is real but smaller than it looks, because the two languages solve the underlying problem differently: Java added
default methods on interfaces, so an interface can gain behavior without breaking implementers, and streams cover a lot of what extension methods on collections are used for. What does not come back is adding a method to String or int, since those are final or primitive. Kotlin has extensions and interoperates with Java, which is one concrete reason Android moved to it.Generics: Reified vs Erased
🚨 Generics are erased here
🚨 Dart's generics are reified — the type argument exists at run time, so
numbers is List<int> works and runtimeType reports it. Java's are erased, so both lists are the same class.void main() {
final numbers = <int>[1, 2, 3];
final words = <String>["a"];
// The type argument SURVIVES to run time.
print(numbers is List<int>);
print(numbers.runtimeType);
print(words.runtimeType);
}import java.util.List;
class Main {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3);
List<String> words = List.of("a");
// The type argument is ERASED. Both are the same class at
// run time, and testing instanceof List<Integer> will not
// even compile.
System.out.println(numbers instanceof List);
System.out.println(numbers.getClass() == words.getClass());
}
}What erasure takes away: no
instanceof List<String>, no new T[10], no T.class, no overloading on List<String> versus List<Integer>, and no primitive type arguments — hence List<Integer>, with a heap object per element. The standard workaround when the type is genuinely needed is to pass Class<T> as an argument. What it buys is that generic code compiles once with no bloat, and interoperates with pre-generics libraries — a compatibility decision from 2004 that the language still lives with.int stops being one type
Java splits numbers into primitives (
int, double) and their boxed object forms (Integer, Double), and only the boxed ones can go in a collection.void main() {
// int and double are the only numeric types, and both are objects
// as far as the type system is concerned.
final int count = 42;
final values = <int>[1, 2, 3];
print(count.toRadixString(16));
print(values.reduce((a, b) => a + b));
print(7 ~/ 2); // integer division has its own operator
print(7 / 2); // and / always gives a double
}import java.util.List;
class Main {
public static void main(String[] args) {
int count = 42; // a PRIMITIVE, not an object
List<Integer> values = List.of(1, 2, 3); // boxed objects
System.out.println(Integer.toHexString(count));
System.out.println(values.stream().mapToInt(Integer::intValue).sum());
System.out.println(7 / 2); // integer division, no ~/
System.out.println(7 / 2.0); // one double makes it double
// 🚨 And the trap erasure and boxing produce together:
Integer a = 1000, b = 1000;
System.out.println(a == b); // false — reference comparison
System.out.println(a.equals(b)); // true
}
}🚨 The last two lines are the classic trap and it is worse than it looks:
Integer values from −128 to 127 are cached and shared, so a == b is true for small numbers and false for large ones — code that tests boxed integers with == passes every test written with small values. Two other differences: 7 / 2 is 3 in Java (Dart makes you write ~/ for that, and / always gives a double), and there is no int that silently becomes a BigInt, so overflow wraps.Collections
List, Map and Set, renamed
The shapes correspond:
List is List, Map is Map, and the functional methods rename — where is filter, map is map, toList() is toList().void main() {
final values = <int>[3, 1, 4];
values.add(1);
values.sort();
print(values);
final counts = <String, int>{"apple": 2};
counts["plum"] = 1;
print(counts["apple"]);
print(counts["fig"]); // null, not an error
print(values.where((v) => v > 1).map((v) => v * 2).toList());
}import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class Main {
public static void main(String[] args) {
List<Integer> values = new ArrayList<>(List.of(3, 1, 4));
values.add(1);
values.sort(null);
System.out.println(values);
Map<String, Integer> counts = new HashMap<>(Map.of("apple", 2));
counts.put("plum", 1);
System.out.println(counts.get("apple"));
System.out.println(counts.get("fig")); // null, same as Dart
System.out.println(values.stream()
.filter(value -> value > 1)
.map(value -> value * 2)
.toList());
}
}Two Java specifics. A collection is declared as an interface (
List) and constructed as an implementation (ArrayList), which is the idiom throughout. And values.stream() is required before the functional methods — they are not on the collection itself, because streams were added in Java 8 to interfaces that could not gain new methods otherwise. A stream is also single-use, where a Dart Iterable can be traversed again.Futures, Isolates & Threads
Future becomes CompletableFuture
CompletableFuture is Future and allOf is Future.wait. What Java lacks is the async/await syntax — the composition is written as method chaining instead.Future<String> work(String name, int milliseconds) async {
await Future.delayed(Duration(milliseconds: milliseconds));
return "$name done";
}
Future<void> main() async {
final results = await Future.wait([
work("first", 20),
work("second", 10),
]);
for (final result in results) print(result);
}import java.util.concurrent.CompletableFuture;
import java.util.List;
class Main {
static CompletableFuture<String> work(String name, int milliseconds) {
return CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(milliseconds); } catch (InterruptedException error) { }
return name + " done";
});
}
public static void main(String[] args) {
var first = work("first", 20);
var second = work("second", 10);
CompletableFuture.allOf(first, second).join();
for (String result : List.of(first.join(), second.join())) {
System.out.println(result);
}
}
}That syntactic loss is real:
thenApply, thenCompose and thenCombine read considerably worse than a sequence of awaits, and error handling through exceptionally and handle is where it gets genuinely awkward. Java's answer is not to add await but to make blocking cheap — virtual threads, in the next row — so that ordinary sequential code scales without any of this. That is the opposite direction from Dart, and it is worth understanding before choosing a style.🚨 Isolates cannot share; threads can
🚨 This is the deepest difference on the page. A Dart isolate has its own heap and shares nothing, so concurrency needs no locks and everything crossing the boundary is copied. Java threads share one heap.
import 'dart:isolate';
// An isolate has its OWN heap. Nothing is shared, so nothing needs
// locking — and everything that crosses is COPIED.
Future<int> heavySum(int limit) async {
return await Isolate.run(() {
var total = 0;
for (var index = 1; index <= limit; index++) total += index;
return total;
});
}
Future<void> main() async {
print(await heavySum(100000));
}class Main {
// Threads share ONE heap. Which is the speed, and the danger:
// two threads writing the same field is a data race, and the
// answer is a lock rather than a copy.
private static long total = 0;
private static final Object guard = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable addMany = () -> {
for (int index = 1; index <= 50000; index++) {
synchronized (guard) { total += index; }
}
};
Thread worker = new Thread(addMany);
worker.start();
addMany.run();
worker.join();
System.out.println(total);
}
}Both directions of the trade are real. Dart's model makes data races impossible and makes passing a large structure between isolates expensive — which is why Flutter code reaches for
compute() on a chunk of work rather than sharing state. Java's makes sharing free and correctness your problem: a data race is not a crash but a stale or torn value, and synchronized, java.util.concurrent.atomic and the concurrent collections are what you use instead. Coming from isolates, the discipline to build is deciding for every mutable field which thread owns it.Virtual threads, and why Java did not add await
This is the strategic difference behind the previous two rows: Dart made everything asynchronous because blocking a single-threaded isolate is fatal, and Java made blocking cheap so that nothing needs to be asynchronous.
// Dart has one thread per isolate and an event loop on it, so
// blocking is forbidden in practice: a synchronous loop freezes the
// UI, and every input-and-output call is async by construction.
//
// final response = await http.get(url); // never blocking
Future<void> main() async {
final value = await Future.value(42);
print(value);
}// Java 21 made virtual threads final, and they are the reason Java
// did not add async/await. A virtual thread is scheduled by the JVM,
// costs a few hundred bytes, and BLOCKS CHEAPLY — so ordinary
// sequential code scales to hundreds of thousands of concurrent
// tasks with no colored functions at all:
//
// try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// for (int index = 0; index < 100_000; index++) {
// executor.submit(() -> { blockingCall(); return null; });
// }
// }
class Main {
public static void main(String[] args) {
System.out.println(42);
}
}For a Dart developer the practical upshot is that the async discipline you have does not transfer, and mostly is not needed. Server-side Java increasingly writes straightforward blocking code and runs it on virtual threads, which is simpler than a
CompletableFuture chain and simpler than async/await. On Android the old rules still apply, because there is a UI thread you must not block, and the answer there is a background executor or Kotlin coroutines.Errors & Checked Exceptions
🚨 Checked exceptions
Dart has no checked exceptions at all — any function may throw anything, and nothing is declared. Java makes the declaration part of the signature and enforces it at every call site.
class EmptyInput implements Exception {
final String message;
EmptyInput(this.message);
@override
String toString() => message;
}
int parse(String text) {
if (text.isEmpty) throw EmptyInput("empty");
return int.parse(text);
}
void main() {
// Nothing in the signature says this can throw, and nothing
// requires the caller to catch it.
try {
print(parse("42"));
print(parse(""));
} on EmptyInput catch (error) {
print("caught: $error");
}
}class EmptyInputException extends Exception {
EmptyInputException(String message) { super(message); }
}
class Main {
// "throws" is part of the SIGNATURE, and the compiler enforces
// it: a caller must catch this or declare it in turn.
static int parse(String text) throws EmptyInputException {
if (text.isEmpty()) throw new EmptyInputException("empty");
return Integer.parseInt(text);
}
public static void main(String[] args) {
try {
System.out.println(parse("42"));
System.out.println(parse(""));
} catch (EmptyInputException error) {
System.out.println("caught: " + error.getMessage());
}
}
}The profession has argued about this for twenty years and both complaints are true: checked exceptions genuinely prevent an unhandled failure mode, and they genuinely produce empty
catch blocks written to silence the compiler. Two practical notes for a Dart developer. Extending RuntimeException makes an exception unchecked, which is what most modern libraries do and what lambdas force, since a checked exception cannot escape a standard functional interface. And try-with-resources is Java's finally for anything that must be closed.Across The Method Channel
The channel, from both ends
This is the errand that brought most readers here. A method channel is a string naming the channel, a string naming the method, and arguments encoded by a standard codec — with nothing checking any of the three.
// The Flutter side. The channel name is a string, the method name
// is a string, and the arguments are a Map — none of it checked.
//
// import 'package:flutter/services.dart';
//
// const platform = MethodChannel("example.test/battery");
//
// Future<int> batteryLevel() async {
// final int level = await platform.invokeMethod("getBatteryLevel");
// return level;
// }
//
// 🚨 A typo in either string is a run-time MissingPluginException,
// and the return type is an unchecked cast.
void main() {
print("one string, three chances to get it wrong");
}// The Android side, in Java. Registered against the same string.
//
// new MethodChannel(messenger, "example.test/battery")
// .setMethodCallHandler((call, result) -> {
// if (call.method.equals("getBatteryLevel")) {
// result.success(readBatteryLevel());
// } else {
// result.notImplemented();
// }
// });
//
// The types that cross are the ones the codec supports: null, bool,
// int, double, String, byte/int/double lists, List and Map. Nothing
// else — no objects, no records, no enums.
class Main {
public static void main(String[] args) {
System.out.println("the boundary is a string and a codec");
}
}So the discipline is to constrain what can go wrong. Declare the channel and method names as constants on both sides and keep them in one document; handle
notImplemented and MissingPluginException rather than assuming the other end exists; keep the payload to the codec's types and validate on arrival; and remember that the handler runs on the Android main thread, so anything slow must move to an executor and call result.success back on the main thread afterwards. Pigeon is the code generator that removes most of this by generating both sides from one definition, and it is worth using.What Java is actually for here
The realistic shape of the work: the Dart side owns the application and the Java side is a thin adapter over something the platform has and Flutter does not.
// The Dart side owns almost everything in a Flutter app: the UI,
// the state, the navigation, the business logic. It reaches for
// platform code only where the platform has something Dart cannot:
//
// sensors, Bluetooth, camera internals
// a vendor SDK that ships as an .aar
// background execution and notifications
// anything wired into Android's own lifecycle
void main() {
print("write as little of the other side as possible");
}// And the Java side is small on purpose. Two things about it that
// a Dart developer has no equivalent for:
//
// 1. The ACTIVITY LIFECYCLE. onCreate, onStart, onResume, onPause,
// onStop, onDestroy — and the process may be killed between any
// of them, so state must be saved and restored.
// 2. The MAIN THREAD RULE. UI touches happen on it, work does not,
// and blocking it for a few frames is a visible stutter.
class Main {
public static void main(String[] args) {
System.out.println("small, and lifecycle-aware");
}
}The advice that follows is to keep the Java side as small as it can be — a translation layer that answers a channel call, with any real logic in Dart where it is testable and shared with iOS. Two things to plan for that Flutter hides from you: the Android process can be killed at almost any point, so a plugin holding state in a field will lose it; and Android's main thread is the UI thread, so a plugin doing work synchronously stutters the very Flutter UI that called it. If the Android side grows past a few hundred lines, it is worth asking whether that logic belongs in Dart.
Tooling
Two toolchains, both good
Neither ecosystem is short of tooling; they differ in whether it comes as one thing.
dart is the runtime, package manager, test runner, formatter and compiler, and Java's equivalents are separate projects that happen to work together.// dart is all of it, and versioned together:
//
// dart create scaffold
// dart run run
// dart test test
// dart format format (non-negotiable, one style)
// dart analyze lint and type-check
// dart compile native or JavaScript
// pub packages, from pub.dev
//
// Hot reload in Flutter is the headline: the running app keeps its
// state and picks up the change.
void main() {
print("one tool, and hot reload");
}// Java's is assembled but stable, and the pieces have not changed
// in a decade:
//
// Maven or Gradle build, dependencies, test, package
// Maven Central one flat resolved version per package
// JUnit test
// Spotless/google-java-format format, if you configure it
//
// No hot reload. HotSwap replaces method bodies in a debugger and
// nothing else, so a changed signature means a restart.
class Main {
public static void main(String[] args) {
System.out.println("two build tools, and a restart");
}
}The loss a Flutter developer feels most is hot reload, which has no Java equivalent — HotSwap swaps method bodies in a debugger and gives up on a changed signature, so the loop is edit, rebuild, restart, navigate back to where you were. What Java offers in exchange is a dependency story that has been stable for twenty years and an ecosystem with a library for everything on the server side. Gradle is what an Android project uses, so a Flutter developer touching the Android side is meeting it whether or not they wanted to.