ZetCode

Classes in Dart

last modified May 30, 2026

In this article we will explore classes in Dart, a powerful feature of the Dart programming language that enables object-oriented programming (OOP).

Dart Classes Overview

Classes are at the core of object-oriented programming in Dart. They act as blueprints for creating objects that encapsulate data and behavior. This tutorial comprehensively covers working with Dart classes, from basic class definitions to advanced concepts such as mixins, extensions, and abstract classes.

By leveraging Dart's class system, developers can write clean, reusable, and modular code. The ability to implement encapsulation, inheritance, and polymorphism makes Dart's class structure an essential tool for designing scalable applications. Modern Dart 3 builds on this foundation with sealed classes, class modifiers, enhanced enums, and pattern matching for safer, more expressive API design.

Feature Description Example
Class Definition Blueprint for creating objects class Point { ... }
Constructors Methods for initializing objects Point({required this.x, required this.y});
Properties Variables within a class double x, y;
Methods Functions within a class void move() { ... }
Inheritance Enables creation of subclasses class Vector extends Point {}
Mixins Allows sharing behaviors across classes class Robot with Walker {}
Abstract Classes Defines structure without implementation abstract class Shape { ... }
Extensions Adds functionality to existing classes extension on String { ... }
Field-formal Parameters Shorthand for assigning constructor args to fields User(this.name, this.age);
Getters & Setters Computed and validated property access double get fahrenheit => ...
Static Members Class-level fields and methods static int count = 0;
Enums Named constants with optional fields and methods enum Status { success(200); ... }
Equality & hashCode Value-based comparison for objects bool operator ==(Object o) => ...
Class Modifiers Control subclassing with final/base/sealed/interface sealed class Result<T> {}

A class in Dart is a user-defined type that combines state (properties) and behavior (methods). It allows developers to model real-world entities, define relationships between objects, and enforce structured programming.

Basic Class Definition

A class in Dart is defined using the class keyword followed by the class name. The body of the class contains its properties (data) and methods (behavior). Here's a simple example of a class representing a point in 2D space:

basic_class.dart
class Point {
  final double x;
  final double y;

  const Point({required this.x, required this.y});

  Point copyWith({double? x, double? y}) =>
      Point(x: x ?? this.x, y: y ?? this.y);

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

void main() {
  const p = Point(x: 3.0, y: 4.0);
  print(p);

  final moved = p.copyWith(x: p.x + 1.0, y: p.y - 1.0);
  print('New position: $moved');
}

The Point class uses final fields and a const constructor, making instances immutable. Named parameters with required prevent accidental argument swaps. The copyWith method is a common Dart pattern for producing modified copies of immutable objects without mutating the original.

In main, the const keyword on the instance signals that the value is a compile-time constant, enabling compiler optimisations and sharing of identical instances. The copyWith call produces a new Point with updated coordinates while the original remains unchanged.

$ dart run basic_class.dart
Point(3.0, 4.0)
New position: Point(4.0, 3.0)

Field-formal Parameters

A field-formal parameter (written this.fieldName) is a constructor parameter that automatically assigns its value to the corresponding instance field. It eliminates the boilerplate of writing this.x = x in the constructor body.

field_formal.dart
class User {
  final String name;
  int age;
  String role;

  // Positional field-formal parameters
  User(this.name, this.age, [this.role = 'guest']);

  // Named field-formal parameters — all required except role
  User.named({required this.name, required this.age, this.role = 'guest'});

  @override
  String toString() => 'User($name, $age, $role)';
}

void main() {
  var u1 = User('Alice', 30);
  var u2 = User('Bob', 25, 'admin');
  var u3 = User.named(name: 'Carol', age: 28, role: 'editor');

  print(u1); // User(Alice, 30, guest)
  print(u2); // User(Bob, 25, admin)
  print(u3); // User(Carol, 28, editor)
}

Field-formal parameters work with both positional and named constructors. When the field is final, the parameter can only assign to it — mutation must be done through a setter or is not possible at all. The optional positional parameter [this.role = 'guest'] shows that default values work exactly the same way as with regular parameters. Named constructors can mix positional and field-formal parameters freely.

$ dart run field_formal.dart
User(Alice, 30, guest)
User(Bob, 25, admin)
User(Carol, 28, editor)

Constructors and Initialization

Dart offers several ways to initialize class instances. Constructors can be simple or complex, with options for named parameters, initializer lists, and factory constructors. Here's a more advanced example demonstrating these features:

constructors.dart
class Rectangle {
  final double width;
  final double height;
  final String color;

  const Rectangle({
    required this.width,
    required this.height,
    this.color = 'black',
  }) : assert(width > 0),
       assert(height > 0);

  Rectangle.square(double size, {String color = 'black'})
      : this(width: size, height: size, color: color);

  Rectangle.fromJson(Map<String, dynamic> json)
      : width = (json['width'] as num).toDouble(),
        height = (json['height'] as num).toDouble(),
        color = (json['color'] as String?) ?? 'black';

  factory Rectangle.fromString(String input) {
    final parts = input.split('x');
    return Rectangle(
      width: double.parse(parts[0]),
      height: double.parse(parts[1]),
    );
  }

  double get area => width * height;
}

void main() {
  var rect1 = Rectangle(width: 10.0, height: 20.0);
  var rect2 = Rectangle.square(15.0, color: 'red');
  var rect3 = Rectangle.fromJson({'width': 5.0, 'height': 8.0});
  var rect4 = Rectangle.fromString('12x24');

  print('Area of rect1: ${rect1.area}');
  print('Area of rect2: ${rect2.area}');
  print('Area of rect3: ${rect3.area}');
  print('Area of rect4: ${rect4.area}');
}

The main const constructor uses named parameters with required for width and height, and an optional color with a default. The assert calls enforce that dimensions are positive at both debug and test time. Rectangle.square is a redirecting named constructor that delegates to the primary constructor with identical width and height. fromJson uses an initializer list with explicit type casts for robustness. The factory constructor fromString parses a dimension string such as 12x24 and returns a validated instance. The area getter demonstrates a computed property.

$ dart run constructors.dart
Area of rect1: 200.0
Area of rect2: 225.0
Area of rect3: 40.0
Area of rect4: 288.0

Getters, Setters, and Computed Properties

Dart supports getter and setter accessors, which provide controlled access to private fields and allow computed properties that look like regular fields to callers.

getters_setters.dart
class Temperature {
  double _celsius;

  Temperature(this._celsius);

  // Getter: read access to private field
  double get celsius => _celsius;

  // Setter: validated write access
  set celsius(double value) {
    if (value < -273.15) throw ArgumentError('Below absolute zero');
    _celsius = value;
  }

  // Computed getter: converts on the fly, no extra field needed
  double get fahrenheit => _celsius * 1.8 + 32;

  // Setter: accepts Fahrenheit and converts to internal Celsius
  set fahrenheit(double f) => _celsius = (f - 32) / 1.8;

  @override
  String toString() =>
      '${_celsius.toStringAsFixed(1)}°C / ${fahrenheit.toStringAsFixed(1)}°F';
}

void main() {
  var t = Temperature(100);
  print(t);             // 100.0°C / 212.0°F

  t.fahrenheit = 32;
  print(t);             // 0.0°C / 32.0°F

  t.celsius = 37;
  print(t);             // 37.0°C / 98.6°F
}

The private field _celsius is the single source of truth. The celsius setter enforces a physical invariant (no temperature below absolute zero) before storing the value. The fahrenheit getter computes its value on every access rather than storing a redundant field, keeping the class in a consistent state automatically. Callers interact with t.fahrenheit as if it were a plain property — the conversion is invisible.

$ dart run getters_setters.dart
100.0°C / 212.0°F
0.0°C / 32.0°F
37.0°C / 98.6°F

Inheritance and Polymorphism

Dart supports single inheritance where a class can extend one other class. The following example demonstrates inheritance, method overriding, and polymorphism:

inheritance.dart
import 'dart:math';

abstract class Shape {
  final String color;

  const Shape(this.color);

  double get area;

  void describe() =>
      print('$runtimeType(color: $color, area: ${area.toStringAsFixed(2)})');
}

class Circle extends Shape {
  final double radius;

  const Circle(String color, this.radius) : super(color);

  @override
  double get area => pi * radius * radius;
}

class Square extends Shape {
  final double side;

  const Square(String color, this.side) : super(color);

  @override
  double get area => side * side;
}

void main() {
  const shapes = [
    Circle('red', 5.0),
    Square('blue', 4.0),
  ];

  for (final shape in shapes) {
    shape.describe();
  }
}

Marking Shape as abstract prevents direct instantiation and declares area as an abstract getter that every subclass must implement. The const constructor propagates to subclasses, enabling compile-time constant instances. Using dart:math's pi constant gives precise area calculations. The toStringAsFixed call in describe formats the output to two decimal places for readability. In main, the list itself is a compile-time constant because every element is const.

$ dart run inheritance.dart
Circle(color: red, area: 78.54)
Square(color: blue, area: 16.00)

Static Members

Static members belong to the class itself rather than to any instance. They are accessed through the class name and are shared across all instances.

static_members.dart
class Counter {
  // Static field: shared across all instances
  static int _count = 0;

  // Static constant
  static const int maxCount = 100;

  // Instance field set from the static counter
  final int id;

  Counter() : id = ++_count {
    if (_count > maxCount) throw StateError('Counter limit reached');
  }

  // Static getter
  static int get count => _count;

  // Static method
  static void reset() => _count = 0;

  @override
  String toString() => 'Counter($id)';
}

void main() {
  var a = Counter();
  var b = Counter();
  var c = Counter();

  print(Counter.count); // 3
  print(a);             // Counter(1)
  print(b);             // Counter(2)

  Counter.reset();
  print(Counter.count); // 0
}

_count is shared state — every Counter() call reads and increments the same variable. Static constants like maxCount are compile-time values and can be used in const expressions. Static methods cannot access this because there is no instance in scope. They are useful for utility functions and factory-style logic that does not depend on object state.

$ dart run static_members.dart
3
Counter(1)
Counter(2)
0

Mixins and Composition

Dart uses mixins to share code across multiple class hierarchies. A mixin is a way to reuse a class's code in multiple class hierarchies without using inheritance. Here's an example demonstrating mixins and composition:

mixins.dart
mixin Logger {
  void log(String message) =>
      print('[${DateTime.now()}] $message');
}

mixin JsonSerializable {
  String toJsonString() {
    return '{"type": "$runtimeType"}';
  }
}

class Person with Logger {
  final String name;
  int age;

  Person(this.name, this.age);

  void celebrateBirthday() {
    age++;
    log('$name is now $age years old');
  }
}

class Product with Logger, JsonSerializable {
  final String id;
  final double price;

  Product(this.id, this.price);

  void applyDiscount(double percent) {
    final discount = price * (percent / 100);
    log('Applying $percent% discount (\$$discount) to product $id');
  }
}

void main() {
  var person = Person('Alice', 30);
  person.celebrateBirthday();

  var product = Product('12345', 99.99);
  product.applyDiscount(10);
  print(product.toJsonString());
}

The Logger mixin adds timestamped logging to any class without inheritance, using bracket-delimited timestamps for readability. The JsonSerializable mixin uses runtimeType to produce a type-aware JSON stub. Person keeps name immutable but allows age to be mutated — declaring age as final would cause a compile error on age++. Product mixes in both behaviours, demonstrating that a single class can compose multiple mixins alongside its own state.

$ dart run mixins.dart
[2025-05-25 14:30:45.123456] Alice is now 31 years old
[2025-05-25 14:30:45.123456] Applying 10% discount ($9.999) to product 12345
{"type": "Product"}

Enhanced Enums

Dart enums are more powerful than in most languages: they can carry fields, methods, and even implement interfaces. Introduced in Dart 2.17, enhanced enums turn what would otherwise be a class into a concise named constant.

enhanced_enums.dart
enum HttpStatus {
  ok(200, 'OK'),
  created(201, 'Created'),
  notFound(404, 'Not Found'),
  serverError(500, 'Internal Server Error');

  final int code;
  final String message;

  const HttpStatus(this.code, this.message);

  bool get isSuccess => code < 400;
  bool get isError => code >= 400;

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

void handleStatus(HttpStatus status) {
  final description = switch (status) {
    HttpStatus.ok || HttpStatus.created => 'Request succeeded',
    HttpStatus() when status.isError     => 'Request failed: $status',
  };
  print(description);
}

void main() {
  print(HttpStatus.ok);           // 200 OK
  print(HttpStatus.notFound);     // 404 Not Found
  print(HttpStatus.ok.isSuccess); // true

  handleStatus(HttpStatus.created);
  handleStatus(HttpStatus.serverError);

  // Iterate over all values
  for (final s in HttpStatus.values) {
    print('\${s.name}: \${s.code}');
  }
}

Each enum constant is a compile-time constant with its own code and message values. The const constructor is required for enhanced enums. The computed getters isSuccess and isError add behaviour without extra storage. The switch expression uses an || pattern to match multiple values in one arm and a when guard to test the property. HttpStatus.values returns the ordered list of all constants, which is useful for iteration and serialisation.

$ dart run enhanced_enums.dart
200 OK
404 Not Found
true
Request succeeded
Request failed: 500 Internal Server Error
ok: 200
created: 201
notFound: 404
serverError: 500

Advanced Class Features

Dart provides several advanced class features including abstract classes, interfaces, extension methods, and operator overloading. The following example demonstrates these concepts:

advanced_features.dart
// Sealed class (Dart 3): all subtypes must be declared in the same library
sealed class Animal {
  String get name;
  void makeSound();
}

// Every class is an implicit interface in Dart
class Bird {
  void fly() => print('Flying high');
}

// Extending a sealed class and implementing an interface
class Parrot extends Animal implements Bird {
  @override
  final String name;

  Parrot(this.name);

  @override
  void makeSound() => print('$name says: Squawk!');

  @override
  void fly() => print('$name is flying in circles');

  // Operator overloading
  Parrot operator +(Parrot other) => Parrot('$name & ${other.name}');
}

// Extension method adds behaviour without modifying the source class
extension on Parrot {
  void repeat(String phrase) =>
      print('$name repeats: $phrase $phrase $phrase');
}

// Exhaustive switch expression enabled by the sealed class
String describeAnimal(Animal a) => switch (a) {
  Parrot(name: var n) => 'A parrot named $n',
};

void main() {
  var polly = Parrot('Polly');
  var matey = Parrot('Matey');

  polly.makeSound();
  polly.fly();

  var couple = polly + matey;
  print('New parrot: ${couple.name}');

  polly.repeat('Hello');
  print(describeAnimal(polly));
}

Declaring Animal as sealed restricts subclassing to the same library, giving the compiler a complete picture of all possible subtypes. This makes switch expressions on Animal exhaustively checkable — if you add a new subtype and forget to handle it, the compiler reports a warning. The describeAnimal function uses a switch expression with Dart 3 object-pattern syntax (Parrot(name: var n)) to destructure the matched value inline. Operator overloading and extension methods remain available on concrete classes regardless of whether the hierarchy is sealed.

$ dart run advanced_features.dart
Polly says: Squawk!
Polly is flying in circles
New parrot: Polly & Matey
Polly repeats: Hello Hello Hello
A parrot named Polly

Equality and hashCode

By default, Dart compares objects by identity (i.e. same memory address). To compare objects by value, you must override operator == and hashCode. These two must always be overridden together: objects that are equal must produce the same hash code.

equality.dart
class Color {
  final int r;
  final int g;
  final int b;

  const Color(this.r, this.g, this.b);

  @override
  bool operator ==(Object other) =>
      other is Color && other.r == r && other.g == g && other.b == b;

  @override
  int get hashCode => Object.hash(r, g, b);

  @override
  String toString() => 'Color($r, $g, $b)';
}

void main() {
  final red1 = Color(255, 0, 0);
  final red2 = Color(255, 0, 0);
  final blue = Color(0, 0, 255);

  print(red1 == red2);           // true  (same values)
  print(red1 == blue);           // false
  print(identical(red1, red2));  // false (different instances)

  // Correct hashCode makes Sets and Maps work as expected
  final palette = {red1, red2, blue};
  print(palette.length);         // 2
}

Object.hash (Dart 2.14+) combines multiple values into a well-distributed hash code, eliminating the need for manual XOR arithmetic. The identical check confirms that red1 and red2 are distinct objects even though they compare equal. A Set uses both == and hashCode: because both colours are equal, the set deduplicates them to a single entry. Always override both together — inconsistent implementations cause silent bugs in collections.

$ dart run equality.dart
true
false
false
2

Class Modifiers

Dart 3 introduced class modifiers that let library authors precisely control how their classes may be used outside the library. Without a modifier, a class can be freely extended, implemented, and mixed in.

Modifier Can extend Can implement Can use as mixin Purpose
final Closed type — no outside subtyping
base Allow extension, forbid implement
interface Allow implement, forbid extend
sealed same lib only same lib only Exhaustive switching; closed hierarchy

The table shows how each modifier restricts class usage. The final modifier prevents all outside subclassing, while base and interface allow specific subtyping patterns. sealed classes are the most powerful: they enforce exhaustive switching and restrict subtypes to the same library.

class_modifiers.dart
// sealed: all subtypes must live in the same library
sealed class Result<T> {}

class Success<T> extends Result<T> {
  final T value;
  Success(this.value);
}

class Failure<T> extends Result<T> {
  final String error;
  Failure(this.error);
}

// Exhaustive: the compiler knows Success and Failure are all subtypes
String handle(Result<int> result) => switch (result) {
  Success(value: var v) => 'Got $v',
  Failure(error: var e) => 'Error: $e',
};

// interface: callers implement the contract, cannot extend
interface class Printable {
  void printInfo();
}

// final: prevent all outside subclassing
final class ImmutableConfig {
  final String host;
  final int port;
  const ImmutableConfig({required this.host, required this.port});
}

void main() {
  print(handle(Success(42)));          // Got 42
  print(handle(Failure('timeout')));   // Error: timeout

  final cfg = ImmutableConfig(host: 'localhost', port: 8080);
  print('\${cfg.host}:\${cfg.port}');    // localhost:8080
}

sealed is the most powerful modifier for domain modelling: because the compiler sees every subtype, a switch expression on a sealed type is statically exhaustiveness-checked. final locks a class completely — useful for value types and configuration objects where uncontrolled subclassing could break invariants. interface communicates that a class is purely a contract: callers must provide a full implementation rather than inheriting one. base is the inverse — subclasses may extend but must honour the base class's invariants.

$ dart run class_modifiers.dart
Got 42
Error: timeout
localhost:8080

Source

Dart Language: Classes
Dart Language: Constructors
Dart Language: Mixins
Dart Language: Extensions
Dart Language: Enums
Dart Language: Class Modifiers
Dart Language: Patterns

Classes are fundamental to Dart's object-oriented programming model. They provide encapsulation of data and behaviour, support inheritance and polymorphism through class hierarchies, and enable code reuse through mixins. Dart 3 extends this with sealed classes, class modifiers, enhanced enums, and exhaustive pattern matching — features that bring compile-time safety and expressive API design to both application and library code. Mastering Dart's class system, from basic field-formal parameters and computed properties to immutable value objects and closed hierarchies, is essential for writing robust Dart and Flutter applications.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Dart tutorials.