Dart Object-Oriented Programming

The Object Class & Type System

50 min Lesson 16 of 24

The Object Class Hierarchy

In Dart, every class implicitly extends Object. This means every value you create -- whether it is a String, int, List, or your own custom class -- is an instance of Object. The Object class sits at the top of the class hierarchy and provides a few fundamental methods that every object inherits.

With Dart’s sound null safety, the type hierarchy actually has two roots:

  • Object -- The root of all non-nullable types. Every non-null value is an Object.
  • Object? -- The root of all types including nullable ones. null is an Object? but NOT an Object.
  • Null -- The type of null, a subtype of every nullable type.
  • Never -- The bottom type, a subtype of every type. No value has type Never.

The Type Hierarchy

void main() {
  // Everything is an Object
  Object a = 42;
  Object b = 'hello';
  Object c = [1, 2, 3];
  Object d = true;

  print(a is Object);  // true
  print(b is Object);  // true
  print(c is Object);  // true

  // null is Object? but NOT Object
  Object? nullable = null;
  print(nullable is Object?);  // true
  print(nullable is Object);   // false -- null is not an Object

  // Every type is a subtype of Object?
  print(42 is Object?);      // true
  print('hi' is Object?);   // true
  print(null is Object?);    // true
}
Key Distinction: In Dart 3.x with null safety, Object means “any non-null value” and Object? means “any value including null.” When you see a function parameter typed as Object, you know it will never be null.

Methods Inherited from Object

The Object class provides three key methods that every class inherits and can override:

toString(), operator ==, and hashCode

class Product {
  final String name;
  final double price;
  final String category;

  const Product(this.name, this.price, this.category);

  // Override toString() for meaningful string representation
  @override
  String toString() => 'Product($name, \$$price, $category)';

  // Override == for value equality
  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Product &&
        other.name == name &&
        other.price == price &&
        other.category == category;
  }

  // Override hashCode (must match ==)
  @override
  int get hashCode => Object.hash(name, price, category);
}

void main() {
  final p = Product('Laptop', 999.99, 'Electronics');

  // toString() is called by print() and string interpolation
  print(p);                     // Product(Laptop, $999.99, Electronics)
  print('Bought: $p');          // Bought: Product(Laptop, $999.99, Electronics)
  print(p.toString());          // Product(Laptop, $999.99, Electronics)

  // Without override, you would see: Instance of 'Product'
}

The runtimeType Property

Every object has a runtimeType property that returns its actual Type at runtime. This is useful for debugging and logging, but should generally not be used for type checking in production code.

Using runtimeType

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

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

class Cat extends Animal {
  Cat(super.name);
}

void main() {
  Animal a = Dog('Rex');
  Animal b = Cat('Whiskers');

  print(a.runtimeType);  // Dog
  print(b.runtimeType);  // Cat

  // Type is the compile-time type, runtimeType is the actual type
  print(a.runtimeType == Dog);  // true
  print(a.runtimeType == Animal);  // false -- it is a Dog, not just Animal

  // Useful for debugging
  void debugPrint(Object obj) {
    print('[${obj.runtimeType}] $obj');
  }

  debugPrint(42);          // [int] 42
  debugPrint('hello');     // [String] hello
  debugPrint(a);           // [Dog] Instance of 'Dog'
  debugPrint([1, 2, 3]);   // [List<int>] [1, 2, 3]
}
Warning: Do not use runtimeType for type checking in production code. Use is instead. The runtimeType property can be overridden, may not work well with generics, and does not account for subtype relationships. It is primarily a debugging tool.

The noSuchMethod() Override

When you call a method that does not exist on an object, Dart normally gives you a compile-time error. However, if a class overrides noSuchMethod(), it can handle calls to undefined methods at runtime. This is used primarily with dynamic types.

noSuchMethod() for Dynamic Proxies

class DynamicLogger {
  final String prefix;

  DynamicLogger(this.prefix);

  @override
  dynamic noSuchMethod(Invocation invocation) {
    final methodName = invocation.memberName.toString();
    final args = invocation.positionalArguments;
    final named = invocation.namedArguments;

    print('[$prefix] Called: $methodName');
    if (args.isNotEmpty) print('  Args: $args');
    if (named.isNotEmpty) print('  Named: $named');

    return null;
  }
}

// More practical: a mock for testing
abstract class Database {
  Future<Map<String, dynamic>> findById(String id);
  Future<List<Map<String, dynamic>>> findAll();
  Future<void> insert(Map<String, dynamic> data);
}

class MockDatabase implements Database {
  final List<Map<String, dynamic>> _data = [];
  final List<String> callLog = [];

  @override
  dynamic noSuchMethod(Invocation invocation) {
    callLog.add(invocation.memberName.toString());
    // Return appropriate default values
    if (invocation.memberName == #findById) {
      return Future.value(<String, dynamic>{});
    }
    if (invocation.memberName == #findAll) {
      return Future.value(_data);
    }
    return Future.value(null);
  }
}

void main() {
  // Dynamic usage
  dynamic logger = DynamicLogger('APP');
  logger.start();           // [APP] Called: Symbol("start")
  logger.process('data');   // [APP] Called: Symbol("process"), Args: [data]

  // Mock database
  final db = MockDatabase();
  // db.findAll(), db.findById('123') etc. are handled by noSuchMethod
}
Tip: In modern Dart, noSuchMethod() is rarely needed because the type system catches most errors at compile time. It is mainly useful for: (1) implementing mock objects for testing, (2) building proxy/wrapper patterns, and (3) handling dynamic JSON-like data structures.

Type Checking with is and is!

The is operator checks if an object is an instance of a specific type (including its subtypes). The is! operator is its negation. Dart also performs type promotion -- after an is check, the variable is automatically treated as the checked type within that scope.

Type Checking and Promotion

abstract class Shape {
  double get area;
}

class Circle extends Shape {
  final double radius;
  Circle(this.radius);

  @override
  double get area => 3.14159 * radius * radius;

  double get circumference => 2 * 3.14159 * radius;
}

class Rectangle extends Shape {
  final double width;
  final double height;
  Rectangle(this.width, this.height);

  @override
  double get area => width * height;

  double get diagonal => (width * width + height * height).sqrt();
}

class Triangle extends Shape {
  final double base;
  final double height;
  Triangle(this.base, this.height);

  @override
  double get area => 0.5 * base * height;
}

void describeShape(Shape shape) {
  print('Area: ${shape.area.toStringAsFixed(2)}');

  // Type promotion: after 'is' check, shape is automatically cast
  if (shape is Circle) {
    // shape is now treated as Circle -- no cast needed!
    print('Circumference: ${shape.circumference.toStringAsFixed(2)}');
  } else if (shape is Rectangle) {
    // shape is now treated as Rectangle
    print('Diagonal: ${shape.diagonal.toStringAsFixed(2)}');
    print('Dimensions: ${shape.width} x ${shape.height}');
  } else if (shape is Triangle) {
    print('Base: ${shape.base}, Height: ${shape.height}');
  }
}

void main() {
  final shapes = <Shape>[
    Circle(5),
    Rectangle(10, 4),
    Triangle(6, 8),
  ];

  for (final shape in shapes) {
    print('--- ${shape.runtimeType} ---');
    describeShape(shape);
  }

  // is! (not is) operator
  final obj = Circle(3);
  if (obj is! Rectangle) {
    print('Not a rectangle');  // Prints this
  }
}
Type Promotion: Dart’s type promotion is smart -- after an is check in an if statement, you can access subclass-specific members without an explicit cast. This works with if, while, conditional expressions, and logical operators. It also works with null checks: if (x != null) promotes x from T? to T.

Type Casting with as

The as keyword performs an explicit type cast. Unlike is (which is safe), as will throw a TypeError at runtime if the cast is invalid.

Safe vs Unsafe Casting

void main() {
  Object value = 'Hello, Dart!';

  // SAFE: Using 'is' first, then type promotion
  if (value is String) {
    print(value.toUpperCase());  // HELLO, DART!
  }

  // UNSAFE: Direct cast with 'as' -- throws if wrong type
  String text = value as String;  // Works because it IS a String
  print(text.length);  // 12

  // This would throw TypeError at runtime:
  // int number = value as int;  // TypeError: String is not int

  // SAFE pattern: try-catch with 'as'
  try {
    final number = value as int;
    print(number);
  } on TypeError {
    print('Cannot cast to int');
  }

  // Casting in collections
  List<Object> mixed = [1, 'two', 3.0, true];

  // Filter and cast safely
  final strings = mixed.whereType<String>().toList();
  print(strings);  // [two]

  final numbers = mixed.whereType<int>().toList();
  print(numbers);  // [1]
}
Best Practice: Always prefer is with type promotion over as casting. The is approach is null-safe and will never throw. Use as only when you are absolutely certain of the type, such as after a previous is check in a different scope, or in tests.

dynamic vs Object vs Object?

These three types are often confused, but they serve very different purposes:

Understanding the Differences

void main() {
  // Object: accepts any non-null value, but restricts access to Object methods
  Object obj = 'hello';
  // obj.toUpperCase();  // ERROR: Object does not have toUpperCase
  print(obj.toString());  // OK: toString() is on Object
  print(obj.hashCode);    // OK: hashCode is on Object

  // Object?: accepts any value including null
  Object? nullable = null;
  nullable = 'hello';
  nullable = 42;
  // nullable.toUpperCase();  // ERROR: same restrictions as Object

  // dynamic: accepts any value, allows ANY method call (no compile-time checks)
  dynamic dyn = 'hello';
  print(dyn.toUpperCase());  // OK at compile time, OK at runtime: HELLO
  dyn = 42;
  // print(dyn.toUpperCase());  // OK at compile time, CRASHES at runtime!
}

// Type safety comparison
void processObject(Object value) {
  // The compiler ensures you only use Object methods
  print(value.toString());
  // value.customMethod();  // Compile ERROR -- caught before running
}

void processDynamic(dynamic value) {
  // The compiler allows ANYTHING -- errors happen at runtime
  print(value.toString());
  // value.customMethod();  // No compile error! Crashes at runtime if method missing
}

// When to use each:
// Object  -- when you need to accept any non-null value but want type safety
// Object? -- when you need to accept any value including null
// dynamic -- when working with JSON, interop, or truly unknown types
//            (use sparingly -- you lose all type safety)
Rule of Thumb: Default to Object or Object? when you need a generic type. Use dynamic only when you genuinely need to call arbitrary methods (like processing JSON data). The stricter the type, the more the compiler helps you catch bugs.

Practical Example: Type-Safe Container

Let’s build a type-safe container that uses the type system effectively -- demonstrating type checking, casting, and the Object class in a real scenario.

TypedBox: A Type-Safe Container

class TypedBox<T extends Object> {
  T _value;

  TypedBox(this._value);

  T get value => _value;

  set value(T newValue) {
    _value = newValue;
  }

  // Check if the stored value is a specific type
  bool containsType<U>() => _value is U;

  // Safe cast: returns null if type does not match
  U? getAs<U>() {
    final v = _value;
    if (v is U) return v;
    return null;
  }

  // Transform the value with type safety
  TypedBox<U> map<U extends Object>(U Function(T) transform) {
    return TypedBox<U>(transform(_value));
  }

  @override
  String toString() => 'TypedBox<${T}>($_value)';
}

// A registry that stores different types safely
class TypeRegistry {
  final Map<Type, Object> _entries = {};

  void register<T extends Object>(T value) {
    _entries[T] = value;
  }

  T? get<T extends Object>() {
    final value = _entries[T];
    if (value is T) return value;
    return null;
  }

  bool has<T>() => _entries.containsKey(T);

  List<Type> get registeredTypes => _entries.keys.toList();
}

void main() {
  // TypedBox usage
  final box = TypedBox<String>('Hello');
  print(box.value);              // Hello
  print(box.containsType<String>());  // true
  print(box.containsType<int>());     // false

  // Safe casting
  final asString = box.getAs<String>();
  final asInt = box.getAs<int>();
  print(asString);  // Hello
  print(asInt);     // null (safe!)

  // Transform
  final lengthBox = box.map<int>((s) => s.length);
  print(lengthBox);  // TypedBox<int>(5)

  // TypeRegistry usage
  final registry = TypeRegistry();
  registry.register<String>('App Config');
  registry.register<int>(42);
  registry.register<List<String>>(['a', 'b']);

  print(registry.get<String>());  // App Config
  print(registry.get<int>());     // 42
  print(registry.get<double>());  // null (not registered)
  print(registry.registeredTypes); // [String, int, List<String>]
}

Pattern Matching with Types (Dart 3.x)

Dart 3 introduced powerful pattern matching that makes type checking even more elegant. You can combine type checks with destructuring in switch expressions and if-case statements.

Modern Type-Based Pattern Matching

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;
  final int code;
  Failure(this.error, this.code);
}

class Loading<T> extends Result<T> {}

// Pattern matching with switch expression
String describeResult(Result<String> result) {
  return switch (result) {
    Success(value: final v) => 'Got: $v',
    Failure(error: final e, code: final c) => 'Error $c: $e',
    Loading() => 'Loading...',
  };
}

// Type patterns in if-case
void processValue(Object value) {
  if (value case int n when n > 0) {
    print('Positive integer: $n');
  } else if (value case String s when s.isNotEmpty) {
    print('Non-empty string: $s');
  } else if (value case List<int> numbers when numbers.length > 2) {
    print('Long int list: $numbers');
  } else {
    print('Other: $value');
  }
}

void main() {
  print(describeResult(Success('data loaded')));  // Got: data loaded
  print(describeResult(Failure('not found', 404)));  // Error 404: not found
  print(describeResult(Loading()));  // Loading...

  processValue(42);          // Positive integer: 42
  processValue('hello');     // Non-empty string: hello
  processValue([1, 2, 3]);   // Long int list: [1, 2, 3]
  processValue(-5);          // Other: -5
}
Summary: The Object class is the root of Dart’s type hierarchy, providing toString(), ==, hashCode, runtimeType, and noSuchMethod(). Use is for safe type checking with automatic promotion, as for explicit (unsafe) casting, and prefer Object over dynamic for type safety. Dart 3.x pattern matching makes type-based logic even cleaner with switch expressions and if-case statements.