Skip to content

Dart Cheatsheet

Client-optimized language for fast apps on any platform.

01

Getting Started

Hello World & Comments

main() is the entry point of every Dart program. Use // for single-line, /* */ for block, and /// for documentation comments. Doc comments support Markdown and are consumed by dartdoc to generate API documentation.

dart
// Hello World in Dart
void main() {
  print('Hello, World!');
}

/* Multi-line
   block comment */

/// Documentation comment
/// Supports Markdown
void greet(String name) {
  print('Hi, $name');
}

Variables: var, final, const

Use var for inferred types, final for runtime constants (assigned once), const for compile-time constants. const is stricter—DateTime.now() cannot be const because its value isn't known at compile time. Prefer final/const over var when the value won't change.

dart
var name = 'Alice';        // type inferred
String city = 'NYC';        // explicit type
final age = 30;             // runtime constant
const PI = 3.14;            // compile-time constant
final now = DateTime.now(); // OK: runtime value
// const time = DateTime.now(); // ERROR: not compile-time

const list = [1, 2, 3];     // const list (immutable)
final list2 = [4, 5, 6];    // final ref, mutable content

Null Safety Basics

Dart has sound null safety. Append ? to make a type nullable (int? can hold null; int cannot). Use ?? for default values, ??= to assign-if-null, ?. for safe access, ! to assert non-null. Non-nullable types are guaranteed non-null, eliminating NullPointerException at compile time.

dart
int value = 42;              // non-nullable
int? nullableValue;          // nullable (default null)
print(nullableValue);        // null
print(nullableValue ?? 0);   // 0 (null coalescing)
nullableValue ??= 10;        // assign if null
print(nullableValue);        // 10

// int x = null;            // ERROR: non-nullable
String name = 'Alice';
print(name.length);          // safe, no null check

Output & Input

print() outputs to stdout with a newline and takes a single argument. Use stdout.write() to suppress the newline. stdin.readLineSync() reads a line from stdin and returns a nullable String. dart:io is not available on the web platform.

dart
import 'dart:io';

void main() {
  print('Hello');                    // stdout with newline
  stdout.write('no newline');        // no trailing newline
  stderr.writeln('an error');        // stderr with newline

  String? input = stdin.readLineSync(); // read a line
  int? n = int.tryParse(input ?? '');   // safe parse
  print('You entered: $n');
}

Type Inference & dynamic

var lets Dart infer the type at compile time (the type cannot change afterward). dynamic disables static type checking—use sparingly. Object is the supertype of all non-null types. Use 'as' to cast, but prefer type-safe checks. dynamic is the only type that permits unknown method calls at compile time.

dart
var x = 10;          // inferred as int
var y = 3.14;        // inferred as double
var s = 'hi';        // inferred as String

dynamic d = 10;      // type can change
d = 'now string';    // OK
// d.foo();          // compiles, may fail at runtime

Object o = 'hello';  // supertype of all non-null types
// o.length;         // ERROR: Object has no length
print((o as String).length);  // 5 (cast)
02

Data Types

Numbers (int, double, num)

num is the supertype of int and double. ~/ is integer division, % or remainder() for modulo. / always returns a double even for int operands. int has bitLength, toRadixString(); double has toStringAsFixed(). On the web target all numbers compile to JS numbers.

dart
int a = 42;
double b = 3.14;
num c = 10;     // num is supertype of int & double
num d = 2.71;
print(a.bitLength);          // 6
print(b.toStringAsFixed(2)); // '3.14'
print(10 ~/ 3);             // 3 (integer division)
print(10.remainder(3));     // 1
print(0xFF);                // 255 (hex)
print(1.5e3);               // 1500.0 (scientific)

Strings

Strings are immutable sequences of UTF-16 code units. Use single or double quotes interchangeably. Triple quotes allow multiline. $var interpolates a variable, ${expr} an expression. The r prefix creates raw strings (no escape processing). Interpolation is preferred over concatenation.

dart
var s1 = 'single';
var s2 = "double";
var s3 = '''multi
line string''';
var name = 'Alice';
print('Hi, $name');               // interpolation
print('Length: ${name.length}'); // expression interpolation

var raw = r'No escape: \n';      // raw string (literal)
var escaped = 'It\'s ok';        // escaped quote
print(s1[0]);                     // 's' (index access)

Booleans

Dart has only two bool values: true and false. Unlike JavaScript there is no truthy/falsy coercion—conditions require an actual bool. Use .isNotEmpty / .isEmpty for collections and strings instead of relying on truthiness.

dart
bool isTrue = true;
bool isFalse = false;
print(!isTrue);           // false
print(isTrue && isFalse); // false
print(isTrue || isFalse); // true

// Only bool is allowed in conditions; no truthy/falsy
// if (1) {} // ERROR: must be bool
if ('text'.isNotEmpty) {
  print('non-empty');
}

Lists

Lists are ordered, growable collections (like arrays). Specify the element type with <Type>[]. Use const for immutable lists. The spread operator ... expands a list into another. Lists use zero-based indexing and are the most common Dart collection.

dart
var list = [1, 2, 3];
var typed = <String>['a', 'b'];
var constList = const [1, 2, 3];
list.add(4);
list.addAll([5, 6]);
print(list.length);          // 6
print(list[0]);              // 1
print(list.sublist(1, 3));   // [2, 3]
var spread = [...list, 7];   // spread
// constList.add(0);         // ERROR: immutable

Maps

Maps are key-value pairs. Keys and values can be any type; specify with <KeyType, ValueType>{}. Access via map[key] returns null if the key is missing—use ?? for a default. Maps preserve insertion order. entries, keys, and values expose iterables.

dart
var map = {
  'name': 'Alice',
  'age': 30,
};
var typed = <String, int>{'a': 1, 'b': 2};
map['city'] = 'NYC';              // add entry
print(map['name']);               // Alice
print(map.length);                // 3
print(map.containsKey('age'));    // true
map.forEach((k, v) => print('$k: $v'));
var keys = map.keys.toList();     // [name, age, city]

Sets & Runes

Sets are unordered collections of unique elements—useful for deduplication and set operations (union, intersection, difference). Runes expose the Unicode code points of a string, which is needed for emoji and non-BMP characters stored as surrogate pairs in UTF-16.

dart
var set = {1, 2, 3};
set.add(2);              // no duplicate added
set.add(4);
print(set);              // {1, 2, 3, 4}
print(set.contains(2));  // true
print(set.intersection({2, 3, 5})); // {2, 3}

// Runes (Unicode code points)
var heart = '♥';        // ♥
print(heart);                // ♥
print('A'.codeUnitAt(0));    // 65
03

Operators

Arithmetic Operators

~/ is Dart's integer division operator (truncates to int). % is modulo. / always returns a double even for int operands. Arithmetic operators work on num, int, and double. Use ~/ when you need an integer result from division.

dart
print(5 + 3);   // 8
print(5 - 3);   // 2
print(5 * 3);   // 15
print(5 / 3);   // 1.6666... (double)
print(5 ~/ 3);  // 1 (integer division)
print(5 % 3);   // 2 (modulo)
print(-(5));    // -5 (unary minus)
print(2.toString()); // '2'

Increment & Decrement

++ and -- have prefix and postfix forms. Postfix (i++) returns the original value then increments; prefix (++i) increments then returns the new value. Behavior matches C/Java. Avoid mixing these into complex expressions for readability.

dart
var i = 5;
print(i++);  // 5 (postfix: use, then add)
print(i);    // 6
print(++i);  // 7 (prefix: add, then use)
print(i--);  // 7
print(--i);  // 5

Relational & Equality

== compares values for built-in types (strings, numbers). For lists and most objects, == compares references by default unless overridden. Use listEquals() from package:collection or flutter/foundation.dart for deep equality of collections.

dart
print(3 == 3);   // true
print(3 != 4);   // true
print(3 < 4);    // true
print(3 > 4);    // false
print(3 <= 3);   // true
print(3 >= 4);   // false
print('a' == 'a'); // true (content equality)
var l1 = [1, 2];
var l2 = [1, 2];
print(l1 == l2); // false (reference equality)

Logical Operators

&& (AND), || (OR), and ! (NOT) operate only on bool. Both && and || short-circuit: && stops if the left side is false, || stops if the left side is true. Unlike some languages, operands must be bool—no truthy/falsy coercion.

dart
bool a = true, b = false;
print(a && b);  // false
print(a || b);  // true
print(!a);      // false

// short-circuit evaluation
bool check() { print('called'); return true; }
false && check();  // check() NOT called
true || check();   // check() NOT called

Type Test: is, as

is checks the type at runtime and enables smart-casting inside the branch (no explicit cast needed). is! is the negation. as performs an unsafe cast that throws TypeError if the type doesn't match. Prefer is checks over as casts for safety.

dart
Object x = 'hello';
print(x is String);   // true
print(x is! int);     // true
if (x is String) {
  print(x.length);    // smart-cast to String
}
Object y = 42;
print((y as int) + 1); // 43 (cast)
// (y as String);      // runtime TypeError

Cascade (..) & Null-aware

.. is the cascade operator—it invokes a method or sets a field and returns the original object, enabling fluent chaining without repeating the variable. ?. is the null-aware access operator (returns null if the target is null). Combine with ?? for defaults.

dart
class Builder {
  String? name;
  int? size;
  Builder setName(String n) { name = n; return this; }
  Builder setSize(int s) { size = s; return this; }
}

var b = Builder()
  ..setName('widget')
  ..setSize(10);
print(b.name); // widget

String? path;
print(path?.length);     // null (safe access)
print(path?.length ?? 0); // 0
04

Control Flow

if-else

if-else conditions must evaluate to bool—no truthy/falsy coercion. The ternary operator ?: works like other C-family languages. else-if chains are common. Dart 3 also supports if as an expression inside collection literals (collection-if).

dart
int score = 85;
if (score >= 90) {
  print('A');
} else if (score >= 80) {
  print('B');
} else {
  print('C');
}

// ternary expression
var grade = score >= 60 ? 'pass' : 'fail';
print(grade); // pass

// if (score) {} // ERROR: condition must be bool

for & for-in

Classic C-style for loops use init; condition; update. for-in iterates any Iterable. For Maps, iterate .entries, .keys, or .values. for-in is preferred when you don't need the index. ${entry.key} uses expression interpolation.

dart
for (var i = 0; i < 3; i++) {
  print(i);
}

var list = ['a', 'b', 'c'];
for (var item in list) {
  print(item);
}

// for-in with Map entries
var map = {'x': 1, 'y': 2};
for (var entry in map.entries) {
  print('${entry.key}: ${entry.value}');
}

while & do-while

while checks the condition before each iteration; do-while checks after, so the body always executes at least once. Both require bool conditions. Use while when the iteration count is unknown ahead of time.

dart
var i = 0;
while (i < 3) {
  print('while $i');
  i++;
}

var j = 0;
do {
  print('do $j');
  j++;
} while (j < 3);

switch (Dart 3)

Dart 3 introduced switch expressions which return a value and use => per case. Patterns support OR (||), AND (&&), relational (>=), and wildcard (_). Classic switch statements require break (no fall-through). Exhaustive matching is required for sealed types and enums.

dart
// Classic switch statement
var color = 'red';
switch (color) {
  case 'red':
    print('stop');
    break;
  case 'green':
    print('go');
    break;
  default:
    print('unknown');
}

// Dart 3 switch expression
String describe(int n) => switch (n) {
  0 => 'zero',
  1 || 2 => 'small',
  >= 3 && <= 10 => 'medium',
  _ => 'large',
};
print(describe(5)); // medium

break & continue

break exits the nearest loop; continue skips to the next iteration. Labels (outer:) allow break/continue to target an outer loop—use sparingly because it reduces readability. break is also used to end switch cases.

dart
for (var i = 0; i < 5; i++) {
  if (i == 2) continue; // skip 2
  if (i == 4) break;    // stop at 4
  print(i);  // 0, 1, 3
}

// labels for nested loops
outer:
for (var i = 0; i < 3; i++) {
  for (var j = 0; j < 3; j++) {
    if (i + j > 2) break outer;
    print('$i,$j');
  }
}

assert

assert(condition, message) checks a condition during development. Assertions are enabled in debug mode and removed in production (release) builds. Use them for internal invariants and debugging—not for input validation that must run in production.

dart
void setAge(int age) {
  assert(age >= 0, 'age must be non-negative');
  // ...
}

assert(1 == 1);  // passes in debug
// assert(1 == 2, 'math is broken'); // fails in debug

// Assertions are stripped in release/production builds
05

Functions

Function Declaration

In Dart, functions are first-class objects—they can be assigned to variables, passed as arguments, and returned. Type aliases like int Function(int) describe function types. Function declarations include optional return type and parameter types.

dart
// named function with return type
int add(int a, int b) {
  return a + b;
}

// functions are first-class objects
int Function(int) makeAdder(int n) {
  return (int x) => x + n;
}

var add5 = makeAdder(5);
print(add5(3)); // 8

print(add(2, 3)); // 5

Arrow Functions

The => (arrow) syntax is shorthand for { return expr; } and is used for single-expression functions. It's common for short methods, getters, and callbacks. Arrow functions make code concise without sacrificing type safety.

dart
// single-expression function with =>
int square(int x) => x * x;
String greet(String name) => 'Hi, $name';

// arrow with nullable
String? firstChar(String? s) => s?.isEmpty ?? true ? null : s[0];

print(square(4));      // 16
print(greet('Al'));    // Hi, Al
print(firstChar('hi')); // h

Optional Positional Parameters

Optional positional parameters are wrapped in square brackets [] and must come after required parameters. They default to null (or a provided default value). Use them when parameter order is intuitive and obvious.

dart
// optional positional params wrapped in []
String greet(String name, [String? title]) {
  if (title != null) {
    return 'Hello, $title $name';
  }
  return 'Hello, $name';
}

print(greet('Alice'));        // Hello, Alice
print(greet('Bob', 'Dr.'));   // Hello, Dr. Bob

Named Parameters

Named parameters are wrapped in {} and passed by name at the call site (order-independent). Use required to mark a named parameter as mandatory. Named parameters default to null unless a default value is given. Preferred for APIs with many optional parameters.

dart
// named params wrapped in {}, required marks mandatory
void createUser({
  required String name,
  int age = 0,
  String? email,
}) {
  print('$name, $age, $email');
}

createUser(name: 'Alice', age: 30);
createUser(name: 'Bob', email: '[email protected]');

// named params are passed by name, order-independent

Default Values

Both optional positional and named parameters can have default values, specified with =. Default values must be compile-time constants. Default values make optional parameters safe by avoiding null when a sensible default exists.

dart
// default values for optional positional
double calc(double a, [double b = 1.0, double c = 0.0]) {
  return a * b + c;
}

// default values for named
void config({String host = 'localhost', int port = 8080}) {
  print('$host:$port');
}

print(calc(5));       // 5.0
print(calc(5, 2));    // 10.0
config(port: 3000);   // localhost:3000

Anonymous Functions & Closures

Anonymous functions (lambdas) have no name and are often assigned to variables or passed as callbacks. Closures capture variables from their enclosing scope and keep them alive. The parameter list can be typed (int a, int b) or untyped (a, b).

dart
// anonymous function assigned to a variable
var multiply = (int a, int b) => a * b;
print(multiply(3, 4)); // 12

// closure capturing a variable
Function counter() {
  int count = 0;
  return () => ++count;
}

var c = counter();
print(c()); // 1
print(c()); // 2

// used as callbacks
[1, 2, 3].forEach((n) => print(n));
06

Classes & Objects

Class Definition

Classes group fields (state) and methods (behavior). The constructor Person(this.name, this.age) uses parameter initializers shorthand to assign fields directly. Unlike Java, Dart classes have a single constructor body; use named constructors for variants.

dart
class Person {
  String name;
  int age;

  // constructor
  Person(this.name, this.age);

  void greet() {
    print('Hi, I am $name');
  }
}

var p = Person('Alice', 30);
p.greet(); // Hi, I am Alice
print(p.name); // Alice

Instance Variables & this

Instance variables (fields) hold per-object state. Use this only when disambiguating from parameters or for clarity. All non-nullable fields must be initialized—via constructor initializers, this.x shorthand, or default values. Fields generate implicit getters/setters.

dart
class Point {
  double x;
  double y;

  Point(this.x, this.y);

  double distanceTo(Point other) {
    return ((x - other.x) * (x - other.x) +
            (y - other.y) * (y - other.y));
  }

  void moveBy(double dx, double dy) {
    this.x += dx;   // 'this' is optional when unambiguous
    y += dy;
  }
}

Getters & Setters

Getters and setters, defined with get/set keywords, let you expose computed properties that look like fields to callers. Use them to validate input or compute derived values without changing the public API. Final fields cannot have setters.

dart
class Rectangle {
  double width, height;
  Rectangle(this.width, this.height);

  // computed getter
  double get area => width * height;
  set size(double v) {
    width = v;
    height = v;
  }
}

var r = Rectangle(3, 4);
print(r.area);  // 12 (accessed like a field)
r.size = 10;
print(r.area);  // 100

Static Members

Static members (fields and methods) belong to the class itself, not instances. They're accessed via ClassName.member. Use static for utility functions and constants shared across all instances. Static members cannot reference non-static members without an instance.

dart
class MathUtils {
  static const double PI = 3.14159;
  static double circleArea(double r) => PI * r * r;
}

// accessed via the class, not an instance
print(MathUtils.PI);             // 3.14159
print(MathUtils.circleArea(2));  // 12.566

// static members belong to the class, not instances

Factory Constructor

A factory constructor (factory keyword) doesn't always create a new instance—it can return a cached one, a subtype, or a pre-built object. Useful for singletons, caches, and returning subclasses. Factory constructors use the class name like normal constructors.

dart
class Logger {
  final String name;
  static final Map<String, Logger> _cache = {};

  // factory may return a cached instance
  factory Logger(String name) {
    return _cache.putIfAbsent(name, () => Logger._internal(name));
  }

  Logger._internal(this.name);
}

var a = Logger('app');
var b = Logger('app');
print(identical(a, b)); // true (same cached instance)

Object & toString

Every class inherits from Object, which provides toString(), ==, hashCode, and runtimeType. Override toString() for readable debugging. If you override ==, you must also override hashCode to maintain the contract for use in Maps and Sets.

dart
class Book {
  String title;
  Book(this.title);

  @override
  String toString() => 'Book($title)';

  @override
  bool operator ==(Object other) =>
      other is Book && other.title == title;

  @override
  int get hashCode => title.hashCode;
}

print(Book('Dart')); // Book(Dart)
print(Book('A') == Book('A')); // true
07

Constructors

Default & Generative Constructor

If you don't define any constructor, Dart provides a default no-argument constructor. A generative constructor creates a new instance. The this.field shorthand in parameters assigns fields directly. Once you define a constructor, the default is no longer generated.

dart
class Animal {
  String species;

  // generative constructor
  Animal(this.species);

  // if no constructor is written, Dart provides:
  // Animal() : species = 'unknown';
}

var a = Animal('cat');
print(a.species); // cat

Named Constructors

Named constructors (ClassName.name) let a class have multiple constructors for different creation patterns. The initializer list (: field = value) runs before the constructor body and can initialize final fields. Useful for factory-style creation and conversion constructors.

dart
class Point {
  double x, y;
  Point(this.x, this.y);

  // named constructor
  Point.origin() : x = 0, y = 0;
  Point.fromList(List<double> l) : x = l[0], y = l[1];
}

var p1 = Point.origin();
var p2 = Point.fromList([3, 4]);
print('${p1.x},${p1.y}'); // 0.0,0.0
print('${p2.x},${p2.y}'); // 3.0,4.0

Initializer List

The initializer list (: expr, expr) runs before the constructor body and can set final fields and run asserts. It's the only place to initialize final fields (other than this.field shorthand). Use it for validation and computed field values.

dart
class Temperature {
  final double celsius;

  // initializer list runs before body
  Temperature(double c) : celsius = c;
  Temperature.fromFahrenheit(double f)
      : celsius = (f - 32) * 5 / 9;

  // assert in initializer list
  Temperature.clamped(double c)
      : assert(c >= -273.15),
        celsius = c < -273.15 ? -273.15 : c;
}

print(Temperature.fromFahrenheit(32).celsius); // 0.0

Redirecting Constructors

A redirecting constructor forwards to another constructor in the same class using 'this(...)'. It has no body and no initializer list of its own. Useful for providing convenience constructors that delegate to a primary constructor.

dart
class Point {
  double x, y;
  Point(this.x, this.y);

  // redirect to another constructor with 'this'
  Point.alongX(double x) : this(x, 0);
  Point.origin() : this(0, 0);
  Point.fromDouble(double n) : this.alongX(n);
}

print(Point.alongX(5).y); // 0.0
print(Point.origin().x);  // 0.0

Constant Constructor

A const constructor (const ClassName) creates compile-time constant instances. All fields must be final. Identical const instances are canonicalized (shared). const constructors enable deeply immutable objects and compile-time constant collections.

dart
class ImmutablePoint {
  final double x;
  final double y;

  // const constructor: creates compile-time constant instances
  const ImmutablePoint(this.x, this.y);

  static const origin = ImmutablePoint(0, 0);
}

const p = ImmutablePoint(1, 2);
const o = ImmutablePoint.origin;
print(identical(o, ImmutablePoint(0, 0))); // true

Factory & Caching

Factory constructors can return const or cached instances and may use Dart 3 switch expressions. They differ from generative constructors which always create a new instance. Use factories when construction logic must choose what to return.

dart
class Shape {
  final String type;
  const Shape._(this.type);

  factory Shape(String kind) {
    return switch (kind) {
      'circle' => const Shape._('circle'),
      'square' => const Shape._('square'),
      _ => const Shape._('unknown'),
    };
  }
}

print(Shape('circle').type); // circle
print(identical(Shape('circle'), Shape('circle'))); // true
08

Inheritance

extends & super

Use extends to inherit from a single superclass (Dart is single-inheritance). The subclass constructor must call a super constructor (often via super(...) in the initializer list). Use @override to mark methods that replace the superclass implementation.

dart
class Animal {
  String name;
  Animal(this.name);

  void speak() => print('$name makes a sound');
}

class Dog extends Animal {
  Dog(String name) : super(name);

  @override
  void speak() => print('$name barks');
}

var d = Dog('Rex');
d.speak(); // Rex barks

@override & super calls

@override is a metadata annotation marking that a method overrides a superclass member. It triggers a compile-time check that the parent method exists. super.method() calls the parent implementation from within an overridden method, allowing extension of behavior.

dart
class Vehicle {
  int speed = 0;
  void accelerate(int by) => speed += by;
  void describe() => print('Vehicle at $speed');
}

class Car extends Vehicle {
  @override
  void accelerate(int by) {
    super.accelerate(by);  // call parent method
    print('Car now at $speed');
  }
}

Car().accelerate(10); // Car now at 10

Constructor Chaining

In inheritance, the superclass constructor runs before the subclass body. The super(...) call in the initializer list passes arguments up. Execution order: initializer list -> super constructor -> this constructor body. This guarantees the parent is fully initialized first.

dart
class Base {
  String tag;
  Base(this.tag) {
    print('Base created: $tag');
  }
}

class Derived extends Base {
  Derived(String tag) : super(tag) {
    print('Derived created');
  }
}

// order: super initializer -> super body -> derived body
Derived('x');
// Base created: x
// Derived created

noSuchMethod

noSuchMethod is invoked when code attempts to call a method or access a field that doesn't exist on an object. Override it to implement dynamic proxies or mock objects. Returning a value lets the call continue; the default throws a NoSuchMethodError.

dart
class Proxy implements Object {
  @override
  dynamic noSuchMethod(Invocation inv) {
    print('Called: ${inv.memberName}');
    return null;
  }
}

var p = Proxy();
p.someMissingMethod(); // Called: Symbol("someMissingMethod")

Sealed Classes (Dart 3)

Sealed classes (Dart 3) are closed hierarchies: all direct subtypes must be in the same library. The compiler enforces exhaustive switch matching over a sealed type's subtypes, making pattern matching safe. Perfect for modeling fixed sets of variants (like a Result/Either type).

dart
sealed class Shape {
  const Shape();
}

class Circle extends Shape {
  final double r;
  const Circle(this.r);
}
class Square extends Shape {
  final double s;
  const Square(this.s);
}

// switch is exhaustive over sealed subtypes
double area(Shape s) => switch (s) {
  Circle(:var r) => 3.14 * r * r,
  Square(:var s) => s * s,
};

print(area(const Circle(2))); // 12.56
09

Abstract Classes & Interfaces

Abstract Class

Abstract classes (abstract class) cannot be instantiated directly and may contain abstract methods (no body) that subclasses must implement. They can also have concrete methods. Use abstract classes to define a partial implementation shared by subclasses.

dart
abstract class Animal {
  // abstract method: no body, must be overridden
  void makeSound();

  // concrete method: inherited as-is
  void breathe() => print('breathing');
}

class Cat extends Animal {
  @override
  void makeSound() => print('meow');
}

var c = Cat();
c.makeSound(); // meow
// Animal();    // ERROR: cannot instantiate abstract class

Implicit Interface

Every Dart class implicitly defines an interface containing all its members. Use implements to implement that interface—you must provide bodies for every member (no code is inherited). A class can implement multiple interfaces, separated by commas.

dart
class Television {
  void turnOn() => print('on');
  void turnOff() => print('off');
}

// every class implicitly defines an interface
class SmartTV implements Television {
  @override
  void turnOn() => print('smart on');
  @override
  void turnOff() => print('smart off');
}

SmartTV().turnOn(); // smart on

implements Multiple

A class can implement multiple interfaces, unlike extends which allows only one superclass. With implements you inherit the type contract but no implementation—every member must be overridden. This enables a form of multiple inheritance of type.

dart
abstract class Flyer {
  void fly();
}
abstract class Swimmer {
  void swim();
}

// implement multiple interfaces
class Duck implements Flyer, Swimmer {
  @override
  void fly() => print('flying');
  @override
  void swim() => print('swimming');
}

Duck().fly();   // flying
Duck().swim();  // swimming

extends vs implements

extends reuses the parent's implementation (single inheritance). implements inherits only the type contract—every member must be re-implemented, but you can implement multiple interfaces. Use extends for 'is-a' with code reuse; implements for 'can-do' contracts.

dart
class Base {
  void greet() => print('hello');
  void wave() => print('waving');
}

// extends: reuse implementation, single parent
class A extends Base {
  @override
  void greet() => print('A says hi');
}

// implements: contract only, must override all
class B implements Base {
  @override
  void greet() => print('B says hi');
  @override
  void wave() => print('B waving');
}

Abstract vs Interface Guidelines

Use abstract classes to share implementation among closely related types (inheritance of code). Use interfaces (abstract classes used via implements) to define capabilities or contracts that unrelated types can fulfill. Dart merges these concepts: an abstract class can serve as both.

dart
// Abstract class: share code among related types
abstract class Repository<T> {
  T? find(int id);          // abstract
  void save(T item) => print('saved'); // shared
}

// Interface: define a capability
abstract class Comparable<T> {
  int compareTo(T other);
}

class Product extends Repository<Product>
    implements Comparable<Product> {
  @override
  Product? find(int id) => null;
  @override
  int compareTo(Product other) => 0;
}
10

Mixins

Define Mixin

A mixin (mixin keyword) is a reusable unit of behavior that can be mixed into classes using 'with'. Mixins cannot have constructors and are not instantiated directly. They're ideal for sharing horizontal functionality across unrelated classes.

dart
mixin Greeter {
  String get name;

  void greet() => print('Hello, $name!');
}

class User with Greeter {
  @override
  String name;
  User(this.name);
}

User('Alice').greet(); // Hello, Alice!

Using Mixins (with)

Apply mixins with the 'with' keyword followed by one or more mixins (comma-separated). A class can mix in multiple mixins, and the order matters when mixins override each other. Mixins are applied linearly to the class hierarchy.

dart
mixin Walker {
  void walk() => print('walking');
}
mixin Talker {
  void talk() => print('talking');
}

// mix in multiple mixins
class Person extends Object with Walker, Talker {
  String name;
  Person(this.name);
}

var p = Person('Bob');
p.walk(); // walking
p.talk(); // talking

Mixin Constraints (on)

The 'on' clause constrains a mixin to only be used with classes that extend (or implement) the specified supertype. This lets the mixin call methods from that supertype. The constraint type acts like a required base for the mixin's behavior.

dart
mixin Musician on Performer {
  void playNote() => print('playing note');
}

abstract class Performer {
  void perform();
}

class Singer extends Performer with Musician {
  @override
  void perform() => print('singing');
}

Singer().playNote(); // playing note
// Musician can only be mixed into Performer subtypes

mixin class (Dart 3)

Dart 3 introduced 'mixin class' which can be used both as a mixin (with) and as a regular class (extends/implements). A plain 'mixin' cannot be extended, and a plain 'class' cannot be mixed in—mixin class bridges both. Useful for sharing behavior that also needs normal class semantics.

dart
// 'mixin class' can be both extended and mixed in
mixin class Counter {
  int _count = 0;
  int get count => _count;
  void increment() => _count++;
}

class App extends Counter {}
class Tool with Counter {}

print(App().count); // 0
App().increment();
Tool().increment();
print(App().count); // 0 (separate instance)

Mixin Method Resolution

When multiple mixins define the same method, the rightmost (last) mixin in the 'with' clause wins, because mixins are applied left-to-right with later ones overriding earlier ones. The class's own methods override all mixins. This linearization determines method resolution.

dart
mixin A {
  void hello() => print('A');
}
mixin B {
  void hello() => print('B');
}

class X with A, B {}
class Y with B, A {}

X().hello(); // B (later mixin wins)
Y().hello(); // A (later mixin wins)

// resolution order: class -> last mixin -> ... -> first mixin
11

Generics

Generic Class

Generics allow classes and methods to operate on any type while preserving type safety. <T> declares a type parameter. The same Stack class works for int, String, or any type without casts. Dart generics are reified (type info is available at runtime), unlike Java's erasure.

dart
class Stack<T> {
  final List<T> _items = [];

  void push(T item) => _items.add(item);
  T pop() => _items.removeLast();
  bool get isEmpty => _items.isEmpty;
}

var s = Stack<int>();
s.push(1);
s.push(2);
print(s.pop()); // 2

var names = Stack<String>();
names.push('Al');

Generic Method

Methods can have their own type parameters independent of the class. Type arguments are usually inferred from the parameters. Generic methods provide type-safe utilities like firstOrDefault, where the return type depends on the input types.

dart
// generic method with its own type parameter
T firstOrDefault<T>(List<T> list, T defaultValue) {
  return list.isEmpty ? defaultValue : list.first;
}

print(firstOrDefault<int>([1, 2, 3], 0)); // 1
print(firstOrDefault([], 'none'));        // none (type inferred)

// type often inferred from arguments

Generic Constraints (extends)

Use 'extends' to constrain a type parameter to a supertype (bound). The constraint lets you call methods of the bound type inside the generic. 'T extends num' ensures T supports arithmetic. Without a constraint, T is treated as Object? and very few methods are available.

dart
class Comparable<T> {
  int compareTo(T other);
}

// constrain T to subtypes of Comparable<T>
T max<T extends Comparable<T>>(T a, T b) {
  return a.compareTo(b) >= 0 ? a : b;
}

// number sum constraint
num sum<T extends num>(List<T> nums) =>
    nums.fold(0, (a, b) => a + b);

print(sum([1, 2.5, 3])); // 6.5

Generic Collections

Dart collections are generic and type information is preserved at runtime (reified). 'is List<int>' works because generics are not erased. Dart collections are covariant: List<int> can be passed where List<num> is expected, which is convenient but can throw at runtime on writes.

dart
List<int> nums = [1, 2, 3];
Map<String, int> scores = {'a': 1, 'b': 2};
Set<double> uniq = {1.1, 2.2, 1.1};

// runtime type checks work (reified generics)
print(nums is List<int>);    // true
print(nums is List<String>); // false
print(scores is Map);        // true

// generic variance
void process(List<num> list) => print(list);
process(nums); // OK: int is a num

Generic Typedef

Typedefs can be generic, creating reusable type aliases for generic function types or class types. Transformer<T> aliases a function T->T. This is invaluable for declaring callback contracts in libraries. Generic typedefs keep complex type signatures readable.

dart
// generic function type alias
typedef Transformer<T> = T Function(T input);

int doubler(int x) => x * 2;
String upper(String s) => s.toUpperCase();

Transformer<int> dt = doubler;
Transformer<String> ut = upper;
print(dt(5));  // 10
print(ut('hi')); // HI

// generic class alias
typedef IntList = List<int>;
IntList xs = [1, 2, 3];
12

Collections (List/Set/Map)

List Operations

Lists support rich functional operations: where (filter), map (transform), fold/reduce (accumulate), sort, reversed. These return Iterables—call .toList() to materialize. Operations are lazy when chained on Iterables. List is Dart's workhorse ordered collection.

dart
var nums = [3, 1, 2];
nums.sort();
print(nums);            // [1, 2, 3]
print(nums.reversed.toList()); // [3, 2, 1]
print(nums.indexOf(2)); // 1
print(nums.contains(3));// true
print(nums.where((n) => n > 1).toList()); // [2, 3]
print(nums.map((n) => n * 2).toList());   // [2, 4, 6]
print(nums.fold(0, (a, b) => a + b));     // 6

Set Operations

Sets provide mathematical set operations: union, intersection, difference. Convert a List to a Set to remove duplicates, then back to a List with toList(). Sets are unordered collections of unique elements—ideal for membership testing and deduplication.

dart
var a = {1, 2, 3};
var b = {2, 3, 4};
print(a.union(b));        // {1, 2, 3, 4}
print(a.intersection(b)); // {2, 3}
print(a.difference(b));   // {1}

var dedup = [1, 1, 2, 3, 3].toSet();
print(dedup.toList());    // [1, 2, 3]
print(dedup.contains(2)); // true

Map Operations

Maps expose keys and values as Iterables. update() modifies a value in place; map() transforms entries into a new Map. forEach iterates key-value pairs. Maps preserve insertion order. Use putIfAbsent for set-if-missing semantics.

dart
var ages = {'Alice': 30, 'Bob': 25};
ages['Carol'] = 28;
print(ages.keys);    // (Alice, Bob, Carol)
print(ages.values);  // (30, 25, 28)
print(ages.length);  // 3

ages.update('Bob', (v) => v + 1);
print(ages['Bob']);  // 26

ages.remove('Alice');
ages.forEach((k, v) => print('$k=$v'));

var mapped = ages.map((k, v) => MapEntry(k, v + 100));

Spread & Null-spread

The spread operator ... expands a collection's elements into another collection. ...? is the null-aware spread—it handles null gracefully (expands to nothing). Spread works on Lists, Sets, and Maps, making collection composition concise.

dart
var a = [1, 2];
var b = [0, ...a, 3];      // [0, 1, 2, 3]
print(b);

List<int>? maybe;
var c = [0, ...?maybe, 4]; // [0, 4] (null-spread is safe)
print(c);

var m1 = {'a': 1};
var m2 = {'b': 2, ...m1};  // {b: 2, a: 1}
print(m2);

Collection-if & Collection-for

Collection-if and collection-for are Dart-specific syntax that build collections conditionally or via iteration inline. They avoid separate add() calls and intermediate variables. They can be combined and nested, producing clean declarative collection construction.

dart
var promo = true;
var menu = [
  'home',
  'products',
  if (promo) 'sale',
  'about',
];
print(menu); // [home, products, sale, about]

var nums = [1, 2, 3];
var doubled = [
  for (var n in nums) n * 2,
];
print(doubled); // [2, 4, 6]

// combine: [for (var x in xs) if (x > 0) x]

Higher-order Methods

Lists/Iterables support any, every, firstWhere, reduce, fold, skip, take, expand, and more. fold is powerful—it carries an accumulator of any type. These higher-order methods enable declarative, functional-style data processing without explicit loops.

dart
var nums = [1, 2, 3, 4, 5];
print(nums.any((n) => n > 4));   // true
print(nums.every((n) => n > 0)); // true
print(nums.firstWhere((n) => n > 2)); // 3
print(nums.reduce((a, b) => a + b));  // 15
var byParity = nums.fold(<bool, List<int>>{}, (m, n) {
  m[n.isOdd] = [...?m[n.isOdd], n]; return m;
});
print(byParity); // {true: [1,3,5], false: [2,4]}
13

Async (Future / async / await)

Future Basics

A Future represents a value (or error) that will be available at some point in the future. Future.delayed completes after a duration. Use .then() to register a callback for when the Future completes. Futures are the foundation of Dart's async model.

dart
Future<String> fetchUser() {
  return Future.delayed(Duration(seconds: 1), () => 'Alice');
}

void main() {
  fetchUser().then((name) {
    print('Got: $name'); // Got: Alice (after 1s)
  });
  print('waiting...');
}

async & await

Mark a function async to use await inside it. async functions always return a Future. await pauses execution until the awaited Future completes, then yields its value—without blocking the event loop. This makes asynchronous code read like synchronous code.

dart
Future<String> fetchUser() async {
  await Future.delayed(Duration(seconds: 1));
  return 'Alice';
}

Future<void> main() async {
  print('start');
  String name = await fetchUser();
  print('Got: $name');
  print('done');
}

Error Handling in Async

Errors thrown in async functions propagate as failed Futures. Use try/catch/finally around await to handle them—identical to synchronous error handling. catch catches the error object; use 'on ExceptionType' to catch specific types.

dart
Future<int> divide(int a, int b) async {
  if (b == 0) throw Exception('divide by zero');
  return a ~/ b;
}

Future<void> main() async {
  try {
    var result = await divide(10, 0);
    print(result);
  } catch (e) {
    print('Error: $e'); // Error: Exception: divide by zero
  } finally {
    print('done');
  }
}

Future.then, catchError

Instead of await, you can chain Futures with .then(), .catchError(), and .whenComplete(). Each .then() returns a new Future, enabling pipelines. catchError handles any failure in the chain. Prefer async/await for readability; use chaining for simple cases.

dart
Future<int> compute() async => 42;

compute()
    .then((v) => v * 2)
    .then((v) => print(v))      // 84
    .catchError((e) => print('err: $e'))
    .whenComplete(() => print('cleanup'));

// chaining transforms the result type
Future<String> fetch() async => 'data';
fetch().then((s) => s.length).then(print); // 4

Future.wait & Future.any

Future.wait runs multiple Futures in parallel and completes when all of them complete (returns a List of results). Future.any completes with the result of the first Future to finish. Use wait for parallelism and any for racing (e.g., request + timeout).

dart
Future<int> task(int n) async {
  await Future.delayed(Duration(milliseconds: n));
  return n;
}

// run in parallel, wait for all
var all = await Future.wait([task(100), task(50), task(200)]);
print(all); // [100, 50, 200]

// resolve with the first to complete
var first = await Future.any([task(100), task(50), task(200)]);
print(first); // 50 (fastest)

Completer

A Completer lets you manually create and complete a Future. Call complete(value) or completeError(error) to finish it. Completers are useful when wrapping callback-based APIs into Futures, or when a Future's completion is triggered by an external event you control.

dart
import 'dart:async';

Completer<String> completer = Completer<String>();

// complete the future from elsewhere
Future<String> get value => completer.future;

completer.complete('resolved!');

void main() async {
  print(await value); // resolved!
}

// useful when bridging callback-based APIs to Futures
14

Stream

Create Stream (async*)

An async* generator function returns a Stream and yields values with yield. Each yield emits a value to the stream. The generator is lazy—values are produced as the stream is consumed. async* is the asynchronous counterpart to sync* (Iterable).

dart
Stream<int> countDown(int from) async* {
  while (from > 0) {
    await Future.delayed(Duration(seconds: 1));
    yield from;
    from--;
  }
}

void main() async {
  await for (var n in countDown(3)) {
    print(n); // 3, 2, 1 (one per second)
  }
}

Listen to a Stream

listen() subscribes to a Stream and registers callbacks for data, errors, and completion. It returns a StreamSubscription which you can pause, resume, or cancel. Single-subscription streams allow only one listener; broadcast streams allow many.

dart
var sub = countDown(3).listen(
  (n) => print('got $n'),
  onDone: () => print('done'),
  onError: (e) => print('err: $e'),
);

// pause/resume/cancel
sub.pause();
sub.resume();
// sub.cancel(); // stop listening

Stream<int> countDown(int from) async* {
  while (from > 0) yield from--;
}

Stream Methods

Streams support functional transformations like where, map, take, skip, and expand, returning new Streams. You can also use first, last, length, isEmpty as Futures. These methods make Stream processing declarative—similar to List but asynchronous.

dart
var stream = Stream.fromIterable([1, 2, 3, 4]);

// transform like an Iterable
var evens = stream.where((n) => n.isEven);
var doubled = stream.map((n) => n * 2);

await for (var n in Stream.fromIterable([1,2,3]).map((n) => n * 10)) {
  print(n); // 10, 20, 30
}

print(await stream.first);  // 1
print(await stream.last);   // 4
print(await stream.length); // 4

StreamController

A StreamController gives you a sink to manually push data, errors, and completion events into a Stream. Use it to create custom streams from non-stream sources. The default is single-subscription; pass broadcast: true for a multi-listener broadcast stream.

dart
import 'dart:async';

var controller = StreamController<int>();

// add events manually
controller.add(1);
controller.add(2);
controller.addError('oops');
controller.close();

controller.stream.listen(
  (n) => print(n),           // 1, 2
  onError: (e) => print(e),  // oops
  onDone: () => print('done'),
);

// use controller.addError/sink.add for errors

await for & Broadcast

await for iterates a Stream asynchronously, processing each event as it arrives until the stream closes. Broadcast streams (created with .broadcast() or StreamController.broadcast) allow multiple simultaneous listeners, while single-subscription streams allow only one.

dart
// await for consumes a stream like a loop
Future<int> sumStream(Stream<int> s) async {
  var total = 0;
  await for (var n in s) {
    total += n;
  }
  return total;
}

print(await sumStream(Stream.fromIterable([1, 2, 3]))); // 6

// broadcast stream: multiple listeners
var bc = StreamController<int>.broadcast();
bc.stream.listen(print);
bc.stream.listen((n) => print('got $n'));
bc.add(5); // both listeners receive 5
15

Exception Handling

throw

throw raises an exception, interrupting normal execution. Dart can throw any non-null object, but it's idiomatic to throw Exception or Error subtypes. Common built-in types: ArgumentError, StateError, FormatException, RangeError. Throwing strings is discouraged.

dart
void checkAge(int age) {
  if (age < 0) {
    throw ArgumentError('age cannot be negative');
  }
  if (age > 150) {
    throw StateError('unrealistic age: $age');
  }
}

// you can throw any non-null object
void fail() => throw 'something went wrong';

try-catch-finally

Use on Type catch (e) to catch specific exception types, or catch (e, stackTrace) to catch anything and access the stack trace. finally always runs whether or not an exception occurred. Order matters: put specific on-clauses before the general catch.

dart
try {
  checkAge(-5);
} on ArgumentError catch (e) {
  print('argument error: $e');
} on StateError catch (e) {
  print('state error: $e');
} catch (e, stackTrace) {
  print('unknown: $e');
  print(stackTrace);
} finally {
  print('always runs');
}

Custom Exception

Create custom exceptions by implementing the Exception interface. Provide a descriptive message and override toString() for readable output. Custom exceptions make error handling more precise—callers can catch specific failure modes. Convention: name them *Exception.

dart
class InvalidCredentialsException implements Exception {
  final String message;
  InvalidCredentialsException(this.message);

  @override
  String toString() => 'InvalidCredentialsException: $message';
}

void login(String user, String pass) {
  if (user.isEmpty) {
    throw InvalidCredentialsException('username required');
  }
}

try {
  login('', 'x');
} on InvalidCredentialsException catch (e) {
  print(e); // InvalidCredentialsException: username required
}

rethrow

rethrow re-raises the currently caught exception while preserving its original stack trace. Use it in middleware/wrapper functions that log or clean up but don't fully handle the error. Unlike throw e, rethrow keeps the original stack trace intact for debugging.

dart
Future<void> logErrors(Future<void> Function() action) async {
  try {
    await action();
  } catch (e) {
    print('logging error: $e');
    rethrow; // re-throw the caught exception
  }
}

void main() async {
  try {
    await logErrors(() async => throw Exception('fail'));
  } catch (e) {
    print('handled upstream: $e');
  }
}

Error vs Exception

Exception is for runtime conditions a program can reasonably catch and recover from (e.g., network failure, bad input). Error represents programming bugs (type errors, assertion failures, index out of range) that should be fixed in code, not caught at runtime. Catching Errors is discouraged.

dart
// Exception: recoverable, expected to be caught
class MyException implements Exception {}

// Error: programming bugs, not meant to be caught
class MyError extends Error {
  @override
  String toString() => 'MyError: invalid state';
}

void risky() {
  throw MyError();   // bug: should fix the code
  throw MyException(); // runtime condition: catch it
}

// assert failures, type errors, range errors are Errors
16

Typedef (Type Aliases)

Function typedef

typedef creates an alias for a function type, making signatures readable and reusable. IntOperator now names the (int,int)->int function type. Without typedef, you'd repeat 'int Function(int, int)' everywhere. Assign compatible functions to variables of the typedef type.

dart
// alias for a function type
typedef IntOperator = int Function(int, int);

int add(int a, int b) => a + b;
int mul(int a, int b) => a * b;

IntOperator op = add;
print(op(2, 3)); // 5
op = mul;
print(op(2, 3)); // 6

// pass as a parameter
void apply(IntOperator f, int a, int b) => print(f(a, b));

Generic typedef

typedefs can be generic, taking their own type parameters. Mapper<T, R> aliases a function T->R. This lets you express rich function contracts once and reuse them. Generic typedefs are essential for typed functional programming patterns in Dart.

dart
// generic function type alias
typedef Mapper<T, R> = R Function(T input);

String stringify(int n) => n.toString();
int lenOf(String s) => s.length;

Mapper<int, String> intToStr = stringify;
Mapper<String, int> strToLen = lenOf;

print(intToStr(42));  // '42'
print(strToLen('hi')); // 2

New-style typedef (non-function)

Since Dart 2.13, typedef can alias any type—including classes, not just functions. This improves readability for complex generic types. Dart 3 records can also be aliased. The alias is fully interchangeable with the original type—no runtime difference.

dart
// Dart 2.13+: alias for any type, not just functions
typedef IntList = List<int>;
typedef StringMap<V> = Map<String, V>;

IntList nums = [1, 2, 3];
StringMap<int> scores = {'a': 1};

// alias for a record type (Dart 3)
typedef Point = ({double x, double y});
Point p = (x: 1.0, y: 2.0);
print(p.x); // 1.0

Using typedef in APIs

typedefs shine in API signatures: they name callback contracts clearly. Predicate<T> reads better than 'bool Function(T)'. Library authors use them to expose clean, documented function-type contracts. Callers can pass any compatible function or lambda.

dart
typedef Predicate<T> = bool Function(T);

bool isEven(int n) => n.isEven;

List<T> filter<T>(List<T> list, Predicate<T> test) {
  return list.where(test).toList();
}

print(filter([1, 2, 3, 4], isEven)); // [2, 4]
print(filter(['', 'a', ''], (s) => s.isNotEmpty)); // [a]

// typedef makes callback contracts explicit

typedef vs inline Function types

A typedef is just an alias—it's identical to the inline function type at runtime and for type checking. typedef improves readability and centralizes the contract so changes happen in one place. Prefer typedef for any function type used in more than one location.

dart
// these two are equivalent
typedef Handler = void Function(String event);

class EventBus {
  // using typedef
  void on(Handler handler) {}
  // using inline type
  void on2(void Function(String) handler) {}
}

// both accept the same functions
void myHandler(String e) => print(e);
EventBus().on(myHandler);
EventBus().on2(myHandler);
17

Enums

Basic Enum

Enums declare a fixed set of named constants. Each value has a name (String) and index (int). values returns a list of all enum values in declaration order. Enums are implicitly static and const. Like switch on enums benefits from exhaustiveness checks.

dart
enum Color { red, green, blue }

var c = Color.red;
print(c);           // Color.red
print(c.name);      // 'red'
print(c.index);     // 0
print(Color.values); // [Color.red, Color.green, Color.blue]
print(Color.green.index); // 1

Enhanced Enum (Dart 3)

Dart 3 enhanced enums can have fields, constructors, methods, and getters—like classes. Constructors must be const. This lets enums carry data and behavior, replacing many uses of class hierarchies. The first values still get automatic name and index properties.

dart
enum Vehicle {
  car('Car', 4),
  bike('Bike', 2),
  truck('Truck', 6);

  final String label;
  final int wheels;
  const Vehicle(this.label, this.wheels);

  int get axles => wheels ~/ 2;
}

print(Vehicle.car.label);   // Car
print(Vehicle.bike.wheels); // 2
print(Vehicle.truck.axles); // 3

Iterate & Switch

Enum values are iterable via .values. Switching over an enum is exhaustive—if you cover all cases, no default is needed (Dart 3 enforces this for sealed types and enums). This makes adding a new enum value a compile-time signal to update every switch.

dart
enum Status { pending, active, done }

// iterate all values
for (var s in Status.values) {
  print(s.name);
}

// exhaustive switch (no default needed)
String label(Status s) => switch (s) {
  Status.pending => 'Waiting',
  Status.active => 'Running',
  Status.done => 'Finished',
};

print(label(Status.active)); // Running

Enum with Methods

Enhanced enums can define methods and getters that use 'this' to refer to the current value. A switch on 'this' inside an enum method is exhaustive without a default. This pattern encapsulates behavior per enum value cleanly, often replacing utility classes.

dart
enum HttpStatus {
  ok(200),
  notFound(404),
  serverError(500);

  final int code;
  const HttpStatus(this.code);

  bool get isSuccess => code >= 200 && code < 300;
  String get reason => switch (this) {
    ok => 'OK',
    notFound => 'Not Found',
    serverError => 'Internal Server Error',
  };
}

print(HttpStatus.ok.isSuccess);   // true
print(HttpStatus.notFound.reason); // Not Found

Enum Comparison

Each enum value is a singleton—there's exactly one instance per value per program. == compares by identity effectively. index allows ordering by declaration position. Use == for equality; identical() also works since values are canonicalized. Enums make great Map keys and Set elements.

dart
enum Priority { low, medium, high }

var a = Priority.low;
var b = Priority.high;

print(a == b);           // false
print(a == Priority.low);// true
print(a.index < b.index);// true
print(identical(a, Priority.low)); // true

// enums are singletons: only one instance per value
// use == for equality, not identical (though both work)
18

Annotations (Metadata)

Built-in Annotations

Annotations start with @ and attach metadata to libraries, classes, fields, parameters, and methods. @override marks method overrides (compile-time check). @deprecated/@Deprecated signal obsolete APIs. @protected, @visibleForTesting, @visibleForOverriding control visibility hints.

dart
class Animal {
  @override
  String toString() => 'Animal';

  @Deprecated('use newName instead')
  String oldName = 'x';
  String newName = 'x';

  @protected
  void internalMethod() {}

  @visibleForTesting
  String testHook() => 'test';
}

@override & @Deprecated

@override tells the compiler to verify a method actually overrides a superclass member—catching typos at compile time. @Deprecated('message') marks an API as obsolete; the analyzer warns at every call site with the provided message, guiding migration to the replacement.

dart
class Base {
  void greet() {}
  String name = 'base';
}

class Derived extends Base {
  @override
  void greet() => print('hi');

  // @override verifies the parent method exists
  // typo here would be a compile error:
  // @override void greeet() {}
}

// @Deprecated emits a warning at the call site
@Deprecated('use bar()')
void foo() {}
void bar() {}

Custom Annotation

Any class with a const constructor can be used as an annotation. Define custom annotations to mark code for tooling, documentation, or code generation. Reading annotations requires dart:mirrors (VM-only, disabled in Flutter) or build_runner code generation. Common in JSON serialization packages.

dart
// a custom annotation is just a const constructor class
class Todo {
  final String msg;
  const Todo(this.msg);
}

class Service {
  @Todo('refactor to use cache')
  void fetchData() {}

  @Todo('add tests')
  void process() {}
}

// annotations are accessed via dart:mirrors (VM) or
// code generation (build_runner) in practice

@immutable & @JsonSerializable

@immutable (from package:meta) marks a class whose instances should not change after construction; subclasses and fields should be final. @JsonSerializable (from json_serializable) triggers code generation for JSON conversion via build_runner. Annotations drive many Dart/Flutter ecosystems.

dart
import 'package:meta/meta.dart';
// requires the 'meta' package

@immutable
class User {
  final String name;
  final int age;
  const User(this.name, this.age);
}

// with package:json_annotation / json_serializable
// @JsonSerializable()
// class Product {
//   final String id;
//   Product(this.id);
//   factory Product.fromJson(Map<String, dynamic> j) => ...;
// }

Annotation on Parameters

Annotations can be placed on parameters, library declarations, and typedefs too. Before null safety, @required marked mandatory named params; the modern equivalent is the 'required' keyword. Annotations on parameters are widely used by serialization and DI frameworks.

dart
class Required {
  const Required([this.reason]);
  final String? reason;
}

class Service {
  // annotation on a parameter
  void create({
    @Required('name is mandatory') String? name,
    @protected int? internal,
  }) {
    print(name);
  }
}

// @required is built into Dart (the 'required' keyword
// is preferred in Dart 2.12+ for null safety)
19

Library & Visibility

import & show/hide

import brings a library's public API into scope. Use show to import only specific names, hide to exclude names, and as to give a prefix (avoiding collisions).dart: is for SDK libraries; package: is for pub packages; relative paths work for local files.

dart
// import an entire library
import 'dart:io';
import 'package:http/http.dart';

// import only specific names
import 'dart:math' show Random, pi;

// hide specific names
import 'dart:async' hide Timer;

// prefix to avoid name clashes
import 'package:http/http.dart' as http;
http.get(Uri.parse('https://example.com'));

export & part

part splits a single library across multiple files (they share the library's private namespace). export re-exports another library's symbols so importers of your library also get them—useful for creating a single public entry point. Prefer export over part for most modular designs.

dart
// library.dart
library my_lib;

// split implementation across files
part 'src/widget_a.dart';
part 'src/widget_b.dart';

// re-export another library's API
export 'src/utils.dart' show formatDate, parseDate;

// users import library.dart and get everything

Visibility (_underscore)

Dart's privacy is library-based, not class-based: identifiers starting with _ are private to the library (the file, or the part-of group) that declares them. The same library can access private members of classes defined in it. There are no protected or package-private keywords.

dart
// names starting with _ are library-private
class _Internal {
  void _helper() {}
}

class Public {
  String _secret = 'hidden';   // private field
  String name = 'visible';     // public field

  String _process() => 'internal';
  String reveal() => _process();
}

// _secret is accessible anywhere in the SAME library/file
// but not from other libraries that import this file

deferred (lazy) loading

Deferred loading (lazy import) downloads a library only when loadLibrary() is first called—useful on the web to reduce initial bundle size. The deferred library's symbols are accessed via the prefix. Supported on web; native platforms load eagerly. Ideal for rarely-used features.

dart
// load a library on demand (web only)
import 'package:heavy_lib/heavy.dart' deferred as heavy;

Future<void> main() async {
  // library is NOT loaded until this call
  await heavy.loadLibrary();
  heavy.SomeClass().doWork();
}

// useful for splitting large web bundles and
// loading rarely-used features only when needed

Library Directive

The library directive names a library and is optional in modern Dart. It's mainly relevant when using part/part-of to split a library across files. Most Dart files omit the directive and are treated as anonymous libraries. The name helps tooling and documentation.

dart
// explicit library declaration
library my_package.utils;

import 'dart:math';

part 'src/helper.dart';

const double version = 1.0;

// a 'library' name is optional in modern Dart;
// it's mainly used with part/part-of and tooling
// most files omit it and are treated as anonymous libraries

part & part of

part/part-of splits one library across files: the main file declares part 'file.dart'; the part file declares 'part of library;'. Parts share the library's scope including private (_name) members. Prefer separate libraries with export for new code—parts are for tightly-coupled implementations.

dart
// shapes.dart
library shapes;
part 'circle.dart';
part 'square.dart';

class Shape {}

// circle.dart
part of shapes;
class Circle extends Shape {}

// files in the same library share private members
// (_name visible across all parts)
20

Null Safety

Nullable & Non-nullable Types

Sound null safety: int can never be null; int? can. The compiler enforces this so NullPointerExceptions are caught at compile time. You must initialize non-nullable variables before use. Append ? to any type to make it nullable. This is Dart's core safety feature since 2.12.

dart
int a = 42;          // non-nullable: cannot be null
int? b;              // nullable: can be null
print(b);            // null
b = 10;
print(b);            // 10

String name = 'Al';  // non-nullable
String? middle;      // nullable

// non-nullable types are guaranteed non-null
// int x = null;      // ERROR
// print(a.length);   // safe: a is non-null

Null Assertion (!)

The ! (null assertion) operator tells the compiler 'I know this is non-null; trust me.' It throws at runtime if the value is actually null. Use it sparingly—prefer ?. or null checks. Overusing ! defeats null safety. It's reasonable when a framework guarantees a value is set.

dart
String? maybeName;
// int len = maybeName.length; // ERROR: maybeName is nullable

maybeName = 'Alice';
int len = maybeName!.length;  // ! asserts non-null
print(len); // 5

// throws if null at runtime:
// String? n; print(n!.length); // NoSuchMethodError/null

Null-aware Operators

?. is null-aware access (returns null if the target is null). ?? is the null-coalescing operator (provides a default). ??= assigns only if the current value is null. Together these let you handle nullable values gracefully without explicit if-null checks.

dart
String? name;
print(name?.length);     // null (safe access)
print(name?.length ?? 0);// 0 (default if null)

name = 'Alice';
print(name?.length);     // 5

name ??= 'Bob';          // assign only if null
print(name);             // Alice (already set)

List<int>? list;
print(list?.first);      // null
print(list?.first ?? -1);// -1

Type Promotion

Type promotion: after a null check (x != null) or type check (x is String), the compiler narrows the type within that branch—no explicit cast needed. Promotion also happens after a non-null assignment. Local variables promote well; fields may need explicit local copies.

dart
String? name;
if (name != null) {
  // name is promoted to non-nullable String here
  print(name.length); // safe, no ! needed
}

// promoted via is check
Object obj = 'hello';
if (obj is String) {
  print(obj.length); // smart-cast to String
}

// promoted via assignment
int? x;
x = 5;
print(x.abs()); // x is non-null after assignment

late & required

late marks a non-nullable variable that will be initialized after declaration but before first use—deferring initialization. late final initializes once (lazily if given an initializer). required marks a named parameter as mandatory. Together they integrate cleanly with null safety.

dart
class Config {
  // late: non-nullable, initialized later
  late final String value = _load();

  // late without initializer: assign before first use
  late final int computed;
  Config() {
    computed = expensive();
  }

  String _load() => 'loaded';
  int expensive() => 42;
}

// required: mandatory named parameter
void build({required String name}) {}
build(name: 'Al'); // OK
// build(); // ERROR: missing required

late Lazy Initialization

A late field with an initializer is lazy—the initializer runs on first access, not at construction. The result is cached for subsequent accesses. This is great for expensive initialization, circular references, and fields that depend on 'this' being fully constructed. late final makes it a one-time computation.

dart
class Service {
  // lazy: _expensive runs only on first access
  late final int cache = _expensive();

  int _expensive() {
    print('computing...');
    return 42;
  }
}

var s = Service();
print('created');
print(s.cache); // computing... 42
print(s.cache); // 42 (cached, no recompute)

// late fields with initializers are evaluated lazily

Was this helpful?