Bryn Flow

🎯 Learn Dart

14 core topics covering the Dart language — the foundation Flutter is built on. Each topic includes a short explanation and a working code sample.

← All Tracks

1 Variables & Type Inference

Dart is statically typed, but you rarely have to write types out by hand. var lets the compiler infer a type from the assigned value, final declares a variable that can be set once at runtime, and const declares a compile-time constant. Choosing the most restrictive one you can (const > final > var) helps catch bugs early.

void main() {
  var name = 'Ava';           // type inferred as String
  final age = 29;             // set once, value known at runtime
  const pi = 3.14159;         // fixed at compile time

  name = 'Ava Chen';          // OK, var can be reassigned
  // age = 30;                // Error: final can't be reassigned

  int explicitType = 10;      // explicit type is also fine
  print('$name is $age, pi is $pi, count is $explicitType');
}

Use const for values like colors, paddings, or list literals in Flutter — it lets the framework skip rebuilding them.

2 Built-in Types

Dart's core types include int and double for numbers (both extend num), String for text, and bool for true/false logic. Strings support single or double quotes, multi-line strings with triple quotes, and interpolation with $variable or ${expression}.

void main() {
  int quantity = 3;
  double price = 4.99;
  bool inStock = true;
  String item = 'coffee';

  double total = quantity * price;
  String summary = '$quantity x $item = \$${total.toStringAsFixed(2)}';

  print(summary);
  print('In stock: $inStock');
  print('Total is a ${total.runtimeType}');
}

int and double both implement num, so you can write functions that accept either.

3 Null Safety

Dart's sound null safety means a variable of type String can never be null unless you mark it String?. The ! operator asserts "I know this isn't null" (and throws if wrong), while ?? supplies a fallback value when the left side is null. This eliminates a huge class of runtime null-reference crashes.

void main() {
  String? nickname;               // nullable, defaults to null
  String displayName = nickname ?? 'Guest';
  print(displayName);             // "Guest"

  nickname = 'Rae';
  print(nickname!.toUpperCase()); // ! asserts non-null: "RAE"

  int? maybeCount;
  int count = maybeCount ?? 0;
  count += 1;
  print('Count: $count');

  String? a;
  a ??= 'default';                // assign only if currently null
  print(a);
}

Only use ! when you're certain the value isn't null — otherwise prefer ?? or an explicit null check.

4 Functions

Functions are first-class values in Dart. Short single-expression functions can use arrow syntax (=>) instead of a block body. Parameters can be positional, named (in curly braces, often with required), or optional (in square brackets), and named parameters make call sites self-documenting.

int square(int x) => x * x;

String greet(String name, {String greeting = 'Hello', bool shout = false}) {
  final message = '$greeting, $name!';
  return shout ? message.toUpperCase() : message;
}

double area(double width, [double? height]) {
  return width * (height ?? width); // defaults to a square
}

void main() {
  print(square(5));
  print(greet('Sam'));
  print(greet('Sam', greeting: 'Hey', shout: true));
  print(area(4));
  print(area(4, 6));
}

Named parameters are required by default only if you mark them required; otherwise give them a default value or make them nullable.

5 Control Flow

Dart supports the usual if/else, for, and while loops, plus a switch statement for branching on a value. for-in loops iterate collections directly, and modern Dart switch statements require every case to be exhaustive or include a default.

void main() {
  int score = 82;

  if (score >= 90) {
    print('A');
  } else if (score >= 80) {
    print('B');
  } else {
    print('C or below');
  }

  for (int i = 0; i < 3; i++) {
    print('for loop: $i');
  }

  final fruits = ['apple', 'banana', 'cherry'];
  for (final fruit in fruits) {
    print('fruit: $fruit');
  }

  int n = 3;
  while (n > 0) {
    print('countdown: $n');
    n--;
  }

  switch (score ~/ 10) {
    case 10:
    case 9:
      print('Excellent');
      break;
    case 8:
      print('Good');
      break;
    default:
      print('Keep practicing');
  }
}

~/ is integer division — useful for bucketing values like grades above.

6 Collections: List, Set, Map

List is an ordered, indexable collection (allows duplicates), Set is an unordered collection of unique values, and Map stores key-value pairs. All three are generic, so List<String> or Map<String, int> tell the compiler exactly what they hold, enabling type-checked access and great tooling support.

void main() {
  List<String> colors = ['red', 'green', 'blue'];
  colors.add('yellow');
  colors.removeAt(0);
  print(colors); // [green, blue, yellow]

  Set<int> uniqueIds = {1, 2, 2, 3};
  uniqueIds.add(3);
  print(uniqueIds); // {1, 2, 3}

  Map<String, int> ages = {'Ava': 29, 'Rae': 34};
  ages['Sam'] = 41;
  print(ages['Ava']);           // 29
  print(ages.containsKey('Sam')); // true

  final doubled = colors.map((c) => c.toUpperCase()).toList();
  final longNames = ages.keys.where((k) => k.length > 3).toList();
  print(doubled);
  print(longNames);
}

Methods like map, where, and toList let you transform collections functionally without manual loops.

7 Classes & Objects

Classes bundle fields (state) and methods (behavior). Dart offers concise constructor syntax where this.field in the parameter list assigns directly to a field, and named constructors let a class expose multiple ways to build an instance.

class Point {
  final double x;
  final double y;

  Point(this.x, this.y); // shorthand constructor

  Point.origin() : x = 0, y = 0; // named constructor

  double distanceTo(Point other) {
    final dx = x - other.x;
    final dy = y - other.y;
    return (dx * dx + dy * dy) < 0 ? 0 : _sqrt(dx * dx + dy * dy);
  }

  double _sqrt(double value) {
    double guess = value / 2 == 0 ? 1 : value / 2;
    for (int i = 0; i < 10; i++) {
      guess = (guess + value / guess) / 2;
    }
    return guess;
  }

  @override
  String toString() => 'Point($x, $y)';
}

void main() {
  final p1 = Point(3, 4);
  final p2 = Point.origin();
  print(p1);
  print(p1.distanceTo(p2));
}

A leading underscore, like _sqrt, makes a member private to its own library (file) by convention.

8 Inheritance & Mixins

A class can extend exactly one superclass to inherit and override its behavior with super and @override. Since Dart only allows single inheritance, mixin lets you share reusable behavior across unrelated classes with with, without forcing a strict "is-a" relationship.

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

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

mixin Swimmer {
  String swim() => 'swimming gracefully';
}

class Dog extends Animal {
  Dog(super.name);

  @override
  String speak() => '${super.speak()}... specifically, a bark!';
}

class Duck extends Animal with Swimmer {
  Duck(super.name);

  @override
  String speak() => '$name quacks and can also ${swim()}';
}

void main() {
  print(Dog('Rex').speak());
  print(Duck('Donald').speak());
}

A mixin has no constructor of its own — it's meant to be "mixed in" with with, not instantiated directly.

9 Exception Handling

try/catch/finally handles runtime errors gracefully: code that might fail goes in try, recovery logic in catch, and cleanup that always runs (like closing a file) in finally. You can also define custom exception classes by implementing Dart's Exception interface for domain-specific errors.

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

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

double withdraw(double balance, double amount) {
  if (amount > balance) {
    throw InsufficientFundsException('Cannot withdraw \$$amount from \$$balance');
  }
  return balance - amount;
}

void main() {
  try {
    final newBalance = withdraw(100, 150);
    print('New balance: $newBalance');
  } on InsufficientFundsException catch (e) {
    print('Caught: $e');
  } catch (e) {
    print('Unexpected error: $e');
  } finally {
    print('Transaction attempt finished');
  }
}

Prefer specific on ExceptionType catch (e) clauses over a bare catch so you don't accidentally swallow unrelated bugs.

10 Futures & async/await

A Future<T> represents a value that will be available later, such as a network response. Marking a function async lets you use await inside it to pause until a Future completes, writing asynchronous code that reads like synchronous code instead of nested callbacks.

Future<String> fetchUserName(int id) async {
  await Future.delayed(const Duration(seconds: 1)); // simulate network call
  return 'User#$id';
}

Future<void> main() async {
  print('Fetching...');
  try {
    final name = await fetchUserName(42);
    print('Got: $name');
  } catch (e) {
    print('Failed: $e');
  }

  // Run two futures concurrently and wait for both
  final results = await Future.wait([
    fetchUserName(1),
    fetchUserName(2),
  ]);
  print(results);
}

Future.wait runs multiple async operations in parallel instead of awaiting them one at a time.

11 Streams

A Stream<T> is like a Future that can emit multiple values over time instead of just one — perfect for events, sensor data, or chunked responses. You consume a stream with .listen() or a for-await loop, and produce one with an async* generator function that uses yield.

Stream<int> countUpTo(int max) async* {
  for (int i = 1; i <= max; i++) {
    await Future.delayed(const Duration(milliseconds: 200));
    yield i;
  }
}

Future<void> main() async {
  final subscription = countUpTo(3).listen((value) {
    print('listen: $value');
  }, onDone: () => print('stream done'));

  await Future.delayed(const Duration(seconds: 1));
  await subscription.cancel();

  // Alternative: consume with a for-await loop
  await for (final value in countUpTo(2)) {
    print('for-await: $value');
  }
}

Streams are broadcast (multiple listeners) or single-subscription (one listener) — async* generators produce single-subscription streams by default.

12 Generics

Generics let a class or function work with any type while keeping type safety, using a placeholder like <T>. This avoids duplicating code for every type and catches type mismatches at compile time instead of at runtime.

class Box<T> {
  final T value;
  Box(this.value);

  R transform<R>(R Function(T) mapper) => mapper(value);

  @override
  String toString() => 'Box($value)';
}

T firstOrDefault<T>(List<T> items, T fallback) {
  return items.isEmpty ? fallback : items.first;
}

void main() {
  final intBox = Box<int>(42);
  final stringBox = intBox.transform<String>((n) => 'Value is $n');
  print(intBox);
  print(stringBox);

  print(firstOrDefault<String>([], 'none found'));
  print(firstOrDefault<int>([7, 8, 9], -1));
}

Generic type parameters can also be constrained, e.g. <T extends num>, to only allow types that satisfy a bound.

13 Extension Methods

Extension methods let you add new functionality to an existing type — including types you don't own, like String or int — without modifying its source or subclassing it. This keeps utility code readable and discoverable via normal dot-notation.

extension StringCasingExtension on String {
  String capitalize() {
    if (isEmpty) return this;
    return this[0].toUpperCase() + substring(1);
  }

  bool get isPalindrome {
    final cleaned = toLowerCase().replaceAll(' ', '');
    return cleaned == cleaned.split('').reversed.join();
  }
}

void main() {
  print('hello world'.capitalize());   // Hello world
  print('Racecar'.isPalindrome);       // true
  print('dart'.isPalindrome);          // false
}

Extensions are resolved statically, so the compiler must know the extension is imported/visible at the call site.

14 Pattern Matching

Dart 3 introduced records (lightweight, unnamed grouped values) and pattern matching, which lets switch statements and expressions destructure data directly in the case clause. This makes working with structured data far more concise than manual field access and if-chains.

(String, int) parseNameAndAge() => ('Ava', 29); // a record

String describe(Object value) {
  return switch (value) {
    (String name, int age) when age >= 18 => '$name is an adult',
    (String name, int age) => '$name is a minor',
    int n when n < 0 => 'negative number: $n',
    int n => 'number: $n',
    _ => 'unknown value',
  };
}

void main() {
  final (name, age) = parseNameAndAge(); // destructuring assignment
  print('$name, $age');

  print(describe(('Sam', 16)));
  print(describe(('Rae', 34)));
  print(describe(-5));
  print(describe(10));
}

Switch expressions (using => per case, no break) must be exhaustive, so a wildcard _ case is a common catch-all.

Practice Quiz

Test what you just learned about Dart.

Ready for another track?

Explore Android, iOS, React Native, or Flutter next.

Back to All Tracks