Thinking in Objects
Welcome to Object-Oriented Dart — a from-scratch course on how Dart models the world with classes and objects. Over the next ten parts we'll go from "what even is a class?" all the way to sealed classes and exhaustive pattern matching, the same toolkit that powers real Flutter apps.
This is Part 1. You don't need any prior OOP experience — just a little Dart syntax (if you've never run a Dart program, skim Getting Started with Dart first). We'll build the mental model first, then write real code.
What is object-oriented programming, really?
Strip away the jargon and OOP is one simple idea: bundle data together with the behavior that operates on that data.
Imagine you're modelling a coffee order. Without OOP, you'd have a loose pile of variables and functions floating around:
String customerName = 'Sam';
String size = 'large';
int shots = 2;
double priceFor(String size, int shots) { /* ... */ }
String describe(String name, String size, int shots) { /* ... */ }
Notice the problem: the data (name, size, shots) and the behavior (priceFor, describe) are completely disconnected. Nothing stops you from calling priceFor with the wrong size, or forgetting which variables belong to which order when you have ten of them.
OOP says: wrap it all up into one thing — a CoffeeOrder — that knows its own data and how to act on it.
var order = CoffeeOrder('Sam', 'large', 2);
print(order.price()); // the order prices itself
print(order.describe()); // the order describes itself
The data and the behavior now travel together as a single unit. That unit is an object, and that's the whole game.
Classes vs. objects vs. instances
These three words get thrown around interchangeably, so let's pin them down with an analogy that actually sticks:
- A class is a blueprint. It describes what something is and what it can do — but it isn't the thing itself. The architectural drawing of a house is not a house.
- An object is a thing built from that blueprint. An actual house you can live in.
- An instance is just another word for "an object built from a particular class." We say a specific house is "an instance of the
Houseclass."
One blueprint, many houses. One class, many instances:
var sam = CoffeeOrder('Sam', 'large', 2);
var alex = CoffeeOrder('Alex', 'small', 1);
sam and alex are two separate instances of the one CoffeeOrder class. They share the same structure (every order has a name, size, and shot count) but hold their own independent data. Changing Sam's order never touches Alex's.
In Dart this goes deep: everything is an object. A number like
42, a string, even a function — all of them are instances of some class, all the way up to a root class calledObject. There are no "primitive" types sitting outside the object world like there are in Java or C++.
The four pillars (your roadmap for this series)
People love to summarize OOP as "four pillars." Don't memorize them as trivia — think of them as the four problems OOP solves, and as a map of where this series is headed:
- Encapsulation — bundling data with behavior and hiding the messy internals behind a clean surface. (Parts 3 & 4)
- Inheritance — letting one class build on another so you don't repeat yourself. (Part 5)
- Polymorphism — letting different objects respond to the same call in their own way. (Parts 5, 6 & 10)
- Abstraction — modelling the idea of a thing (a "Shape", a "PaymentMethod") without committing to one concrete version. (Parts 6 & 7)
We'll meet each one properly. For now, just know the vocabulary exists.
Defining your first class
Enough theory. Here's the simplest useful class in Dart:
class CoffeeOrder {
String customerName;
String size;
int shots;
CoffeeOrder(this.customerName, this.size, this.shots);
}
Three things are happening here:
class CoffeeOrder { ... }declares the blueprint. Class names useUpperCamelCaseby convention — that's how Dart developers spot a type at a glance.- The three lines inside (
customerName,size,shots) are instance variables, also called fields. They're the data every order carries. CoffeeOrder(this.customerName, ...)is a constructor — the recipe Dart runs to build a new object. Thatthis.customerNameshorthand means "take the value passed in and store it in this object'scustomerNamefield." (We'll spend all of Part 2 on constructors; for now just know it wires the data in.)
Creating and using objects
To build an instance, you call the class like a function:
void main() {
var order = CoffeeOrder('Sam', 'large', 2);
print(order.customerName); // Sam
print(order.size); // large
print(order.shots); // 2
}
A couple of things worth noticing:
No new keyword. Some languages make you write new CoffeeOrder(...). Dart used to as well, but it's been optional for years — idiomatic Dart just writes CoffeeOrder('Sam', 'large', 2). You'll still see new in old code; you never need to write it.
Dot notation (order.customerName) reaches into the object to read a field. The same . will later call methods (order.price()).
Safely reaching into maybe-null objects
If an object might be null, reaching into it with a plain . would crash. Dart gives you ?. — the null-aware access operator — which short-circuits to null instead of throwing:
CoffeeOrder? maybeOrder = findOrder('Sam'); // might return null
var name = maybeOrder?.customerName; // null if maybeOrder is null
If maybeOrder is null, the whole expression is just null — no crash. This pairs hand-in-glove with Dart's null safety, and you'll lean on it constantly.
Giving objects behavior: a first method
A class with only fields is just a fancy data bag. The power of OOP shows up when objects do things. A function that lives inside a class is called a method:
class CoffeeOrder {
String customerName;
String size;
int shots;
CoffeeOrder(this.customerName, this.size, this.shots);
// A method — behavior that belongs to the order.
String describe() {
return '$customerName ordered a $size coffee with $shots shot(s).';
}
}
void main() {
var order = CoffeeOrder('Sam', 'large', 2);
print(order.describe());
// → Sam ordered a large coffee with 2 shot(s).
}
Look closely at describe(): it uses customerName, size, and shots without any prefix. Inside a method, the object's own fields are right there in scope. The method automatically operates on whichever instance you called it on — call sam.describe() and it reads Sam's data; call alex.describe() and it reads Alex's.
That "the method knows which object it belongs to" magic has a name: this.
The this keyword
this is a reference to the current object — the specific instance a method was called on. Most of the time you don't need to write it, because field names are already in scope. But it becomes essential when a local name would otherwise shadow (hide) a field:
class CoffeeOrder {
String size;
CoffeeOrder(this.size);
void resize(String size) {
// Here `size` (the parameter) shadows the field `size`.
// `this.size` is the field; plain `size` is the parameter.
this.size = size;
}
}
Without this., the line size = size would just assign the parameter to itself and the field would never change — a classic silent bug. this.size = size says clearly: "set this object's field to the value that was passed in."
Rule of thumb: skip
thisfor everyday field access (Dart's style guide even prefers you omit it), and reach for it only when you need to disambiguate from a parameter of the same name.
Inspecting an object's type
Every object knows what class it came from. You can ask at runtime with runtimeType:
var order = CoffeeOrder('Sam', 'large', 2);
print(order.runtimeType); // CoffeeOrder
That's handy for debugging and logging. But for decisions in your code — "is this object a CoffeeOrder?" — don't compare runtimeType. Use the is type-test operator instead:
if (order is CoffeeOrder) {
// Dart even "promotes" order to CoffeeOrder inside this block,
// so you can use its members directly.
print(order.describe());
}
is is safer (it respects subtypes, which matter once we hit inheritance in Part 5) and triggers type promotion, where Dart automatically treats the variable as the narrower type inside the if. We'll use this pattern a lot later.
Putting it all together
Here's the complete picture — a small but real class with data, a constructor, and behavior:
class CoffeeOrder {
String customerName;
String size;
int shots;
CoffeeOrder(this.customerName, this.size, this.shots);
double price() {
const sizePrices = {'small': 3.0, 'medium': 3.5, 'large': 4.0};
final base = sizePrices[size] ?? 3.0;
return base + shots * 0.5; // each shot adds 50 cents
}
String describe() {
return '$customerName: $size, $shots shot(s) — \$${price().toStringAsFixed(2)}';
}
}
void main() {
final orders = [
CoffeeOrder('Sam', 'large', 2),
CoffeeOrder('Alex', 'small', 1),
];
for (final order in orders) {
print(order.describe());
}
// → Sam: large, 2 shot(s) — $5.00
// → Alex: small, 1 shot(s) — $3.50
}
Notice how describe() calls price() — methods on the same object can freely call each other. And each order in the list prices and describes itself, using its own data. That's encapsulation quietly doing its job.
Practice Challenges
Try each one yourself before peeking. There's rarely one "right" answer — if yours works and reads clearly, you're good.
Challenge 1 — Model a Book. Create a Book class with title, author, and pages fields and a constructor. Build two books and print each title.
Show solution
class Book {
String title;
String author;
int pages;
Book(this.title, this.author, this.pages);
}
void main() {
var b1 = Book('Dune', 'Frank Herbert', 412);
var b2 = Book('The Hobbit', 'J.R.R. Tolkien', 310);
print(b1.title); // Dune
print(b2.title); // The Hobbit
}
The constructor's this.title shorthand wires each argument straight into the matching field — that's all it takes to get data into an object.
Challenge 2 — Add behavior. Give Book a summary() method that returns "Dune by Frank Herbert (412 pages)".
Show solution
class Book {
String title;
String author;
int pages;
Book(this.title, this.author, this.pages);
String summary() => '$title by $author ($pages pages)';
}
void main() {
var b = Book('Dune', 'Frank Herbert', 412);
print(b.summary()); // Dune by Frank Herbert (412 pages)
}
Inside summary() the fields are in scope directly — no this needed. I used Dart's arrow syntax (=>) because the method is a single expression.
Challenge 3 — Independent instances. Create two Book objects, change the pages of the first one, and print both. Confirm the second is untouched.
Show solution
void main() {
var b1 = Book('Dune', 'Frank Herbert', 412);
var b2 = Book('The Hobbit', 'J.R.R. Tolkien', 310);
b1.pages = 500;
print(b1.pages); // 500
print(b2.pages); // 310 — completely unaffected
}
This is the key insight from "one class, many instances": each object owns a separate copy of its fields. Mutating b1 can never reach into b2.
Challenge 4 — Use this. Add a method renameTo(String title) that updates the book's title, correctly handling the name clash between the parameter and the field.
Show solution
class Book {
String title;
String author;
int pages;
Book(this.title, this.author, this.pages);
void renameTo(String title) {
this.title = title; // field = parameter
}
}
Because the parameter is also called title, plain title refers to the parameter. this.title is the only way to reach the field. (You could rename the parameter to newTitle and skip this — both are valid.)
Challenge 5 — Type checking. Given a List<Object> items that mixes Books and Strings, print the summary of each Book and skip everything else.
Show solution
void main() {
List<Object> items = [
Book('Dune', 'Frank Herbert', 412),
'just a note',
Book('1984', 'George Orwell', 328),
];
for (final item in items) {
if (item is Book) {
// `item` is promoted to Book inside this block.
print(item.summary());
}
}
}
The is check both filters and promotes — inside the if, Dart lets you call Book methods on item with no cast. This is far better than comparing runtimeType.
Questions to test yourself
From basic to advanced. Think first, then check.
Q1 (basic). What's the difference between a class and an object?
Show answer
A class is a blueprint — a description of what something is and can do. An object (an instance) is a concrete thing built from that blueprint. One class can produce many objects, each with its own data.
Q2 (basic). What's the difference between a field and a method?
Show answer
A field (instance variable) is data an object holds — like customerName. A method is behavior — a function that lives inside the class and acts on that data, like describe().
Q3 (intermediate). Why does order.customerName work without writing this., but this.size = size inside resize(String size) needs the this.?
Show answer
Inside a method, fields are already in scope, so customerName resolves to the field directly. But when a parameter has the same name as a field (size), the parameter shadows the field — plain size means the parameter. You need this.size to reach the field and break the ambiguity.
Q4 (intermediate). Why prefer if (order is CoffeeOrder) over if (order.runtimeType == CoffeeOrder)?
Show answer
Two reasons. First, is respects subtypes — a subclass of CoffeeOrder passes an is CoffeeOrder check but fails a runtimeType == comparison (which matters once inheritance enters the picture). Second, is triggers type promotion: Dart automatically treats the variable as the narrower type inside the block, so you can use its members without a cast.
Q5 (advanced). Dart says "everything is an object, even 42." What does that statement actually buy you as a programmer — give a concrete example?
Show answer
Because numbers are real objects (instances of int/double, which descend from Object), they carry methods and properties you can call directly: 42.isEven, 3.14.round(), 5.toString(), (-7).abs(). There are no separate "primitive" values that you'd have to box into wrapper objects first — the language is uniform, so generics, collections, and Object-typed variables all work seamlessly with numbers too.
Q6 (advanced). Consider:
var a = CoffeeOrder('Sam', 'large', 2);
var b = a;
b.shots = 5;
print(a.shots); // ?
What prints, and why?
Show answer
It prints 5. Objects in Dart are reference types — var b = a copies the reference, not the object. So a and b point to the same CoffeeOrder in memory, and mutating through b is visible through a. (Contrast this with Challenge 3, where b1 and b2 were two different objects.) Understanding reference vs. value semantics is essential — we'll come back to it when we discuss equality in Part 4.
Wrapping up
You now have the foundation the rest of the series builds on:
- A class is a blueprint; an object/instance is a thing built from it. One class, many independent instances.
- Fields hold an object's data; methods give it behavior. Bundling them is the heart of OOP.
- Build objects by calling the class (no
newneeded); reach into them with.or the null-safe?.. thisrefers to the current object — needed mainly to escape name shadowing.- Use
is(notruntimeType) to test types, and enjoy the free type promotion. - Everything in Dart is an object, and objects are passed by reference.
Next up in Part 2, we go deep on the single most flexible feature of Dart classes: constructors — named, redirecting, const, and factory. It's where building objects gets genuinely fun.