The Object Class & Type System
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 anObject.Object?-- The root of all types including nullable ones.nullis anObject?but NOT anObject.Null-- The type ofnull, a subtype of every nullable type.Never-- The bottom type, a subtype of every type. No value has typeNever.
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
}
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]
}
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
}
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
}
}
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]
}
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)
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
}
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.