Dart for C# Developers, Part 2: Classes, Primary Constructors, Mixins, Sealed Classes and Pattern Matching
In Dart, a switch over a sealed hierarchy stops compiling when a new subtype is added and names the missing case, while value equality does not come without writing == and hashCode. We cover the kinds of constructors, how Dart 3.13 primary constructors differ from C# 12, class modifiers, mixins and extension types with real output, and write Bookstore's OrderStatus and Money types.
Version note: The Dart examples in this post were run with Dart 3.13.7 (the SDK that ships with Flutter 3.47.8), and the Flutter tests with Flutter 3.47.8. Primary constructors are on in Dart 3.13 without any extra setting; they do not compile on 3.12 and earlier.
Guess First
In the mobile app of Bookstore, the online bookstore used as the shared example across this site's series, the catalog comes from two separate API calls: one for "bestsellers", one for "new releases". Dune is in both. To merge the two responses and drop duplicates, you put the books into a Set:
final a = PlainBook('9780441172719', 'Dune');
final b = PlainBook('9780441172719', 'Dune');
print({a, b}.length);
PlainBook is an ordinary class with just two final fields. Does it print 1 or 2? The second question: you modelled the order status with four subtypes (Pending, Shipped, Delivered, Cancelled) and produce labels with a switch everywhere. When the team adds a fifth status, Returned, who notices first: the compiler, a test, or the user?
Kinds of Constructors: Generative, Named, Factory and Const
In the previous post we saw the simple constructor that assigns fields with this.isbn. Dart has several kinds of constructors, and unlike C# you cannot write more than one constructor overload with the same name in a class; different ways of construction become named constructors:
// Constructor kinds: generative, named, initializer list, const, factory, primary (post 04).
class Category {
// Generative constructor with an initializer list and an assert
Category(String name)
: name = name.trim(),
slug = name.trim().toLowerCase().replaceAll(' ', '-'),
assert(name.trim().isNotEmpty);
// Named constructor redirecting to the main one
Category.uncategorized() : this('Uncategorized');
// Factory constructor: may return a cached instance
factory Category.cached(String name) =>
_cache.putIfAbsent(name, () => Category(name));
static final _cache = <String, Category>{};
final String name;
final String slug;
}
// Primary constructor (Dart 3.13): fields and constructor in the class header
class const Publisher(final String name, {final String country = 'GB'});
class Warehouse(final String code, int capacity) {
// Regular parameters are visible in field initializers
final int reservedForReturns = capacity ~/ 10;
this : assert(code.length == 3);
}
// Private named parameter (Dart 3.12): callers write `items:`, the field stays private
class Cart {
Cart({required this._items});
final List<String> _items;
int get count => _items.length;
}
void main() {
final scifi = Category(' Science Fiction ');
print('${scifi.name} -> ${scifi.slug}');
print(Category.uncategorized().slug);
print(
'cached: ${identical(Category.cached('Classics'), Category.cached('Classics'))}',
);
const penguin = Publisher('Penguin');
print(
'${penguin.name}, ${penguin.country}, const: ${identical(penguin, const Publisher('Penguin'))}',
);
final ist = Warehouse('IST', 5000);
print('${ist.code} reserves ${ist.reservedForReturns}');
print('cart: ${Cart(items: ['Dune', 'Emma']).count}');
}
Science Fiction -> science-fiction
uncategorized
cached: true
Penguin, GB, const: true
IST reserves 500
cart: 2
In Category's main constructor, the part after : is the initializer list: fields are assigned here before the constructor body runs. You cannot give final fields a value in the body, which is why the initializer list exists. In C# you can assign a readonly field in the constructor body; in Dart all final fields must be set before the body runs.
Category.uncategorized() is a named constructor; in C# you would usually write a static factory method for this (Category.Uncategorized()). It redirects to the main constructor with : this(...), the same idea as the : this(...) chain in C#. A factory constructor does not have to create a new instance: it can return an existing one from a cache, or even an instance of a subtype. The caller does not see the difference; Category.cached('Classics') reads like an ordinary constructor call, and cached: true in the output shows the two calls returned the same object.
Note the assert in the initializer list. In Dart, asserts run only during development: they are on in flutter run's debug mode and in flutter test, and the code is not present at all in a release build. dart run does not run asserts by default. When we tried Warehouse('ISTANBUL', 10), plain dart run passed silently, while dart run --enable-asserts stopped:
Unhandled exception:
'file:///home/user/src/bookstore-flutter/scratch/bin/a.dart': Failed assertion: line 2 pos 17: 'code.length == 3': warehouse code must have 3 letters
The rule is simple: an assert is a check aimed at the developer; to validate data that comes from the user, you write a real check that throws an exception.
Dart 3.13 Primary Constructors and How They Differ From C# 12
Publisher and Warehouse use the primary constructor syntax that arrived with Dart 3.13. The parameter list sits right next to the class name. Parameters marked with final or var declare a field with the same name; since Publisher's body is empty, ; is enough instead of {}. class const Publisher(...) makes the constructor const, which is where const: true in the output comes from.
At first glance it looks the same as C# 12's primary constructors, but the meaning is different. As the C# series post "What's New in C# 12: Primary Constructors and Collection Expressions" explains, in C# the parameters of class Warehouse(string code, int capacity) do not become fields; parameters used inside the class are captured in a hidden place the compiler generates, and they are even mutable. In Dart you decide which parameter becomes a field: final String code is an immutable field, while int capacity is a plain parameter visible only during construction. The reservedForReturns field can use capacity directly in its initializer; the assert and the initializer list move to the this : line in the body.
this._items in Cart is Dart 3.12's private named parameter. In Dart, names starting with _ are private to the library; before, to fill a private field through a named parameter you had to give the parameter a public name and assign it in the initializer list. Now the caller writes items: and the field stays _items. It is handy in Flutter widgets for taking state from the constructor without exposing it.
Every Class Is an Interface: implements and Class Modifiers
Dart has no separate kind of type declared with an interface keyword; every class is also an interface. If another class applies it with implements, it does not get the code, it only takes on the contract and writes every member itself. That is why writing a fake clock for tests is a one-liner:
// Implicit interfaces, class modifiers, mixins, extension methods and extension types (post 04).
// Every class is also an interface: a fake can `implements` it without inheriting code
class Clock {
DateTime now() => DateTime.now().toUtc();
}
class FixedClock implements Clock {
FixedClock(this.fixed);
final DateTime fixed;
@override
DateTime now() => fixed;
}
// interface class: can be implemented outside this library, not extended
interface class PaymentGateway {
bool charge(int kurus) => kurus > 0;
}
// A mixin adds behaviour without a base class
mixin Auditable {
final List<String> log = [];
void audit(String message) => log.add(message);
}
class StockItem with Auditable {
StockItem(this.isbn, this.quantity);
final String isbn;
int quantity;
void reserve(int count) {
quantity -= count;
audit('reserved $count of $isbn, left $quantity');
}
}
// Extension method: like a C# static extension method
extension on DateTime {
String toShortDate() =>
'${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
}
// Extension type: a zero-cost wrapper around int, checked only at compile time
extension type const Money(int kurus) {
Money operator +(Money other) => Money(kurus + other.kurus);
Money operator *(int quantity) => Money(kurus * quantity);
String format() =>
'${kurus ~/ 100}.${(kurus % 100).toString().padLeft(2, '0')} TRY';
}
void main() {
final clock = FixedClock(DateTime.utc(2026, 11, 15, 9, 30));
print('today: ${clock.now().toShortDate()}');
print('charge: ${PaymentGateway().charge(48900)}');
final dune = StockItem('9780441172719', 12)..reserve(2);
print(dune.log);
const price = Money(48900);
final total = price * 2 + const Money(21500);
print('total: ${total.format()}');
print(
'at run time: ${total.runtimeType}, is int: ${(total as Object) is int}',
);
}
today: 2026-11-15
charge: true
[reserved 2 of 9780441172719, left 10]
total: 1193.00 TRY
at run time: int, is int: true
In C# you would write an IClock interface and two classes implementing it for the test. In Dart, Clock itself is the contract; FixedClock implements it. The "fakes over mocks" recommendation in Flutter's official architecture guide relies on this feature too: in the testing post we write fakes of the repositories the same way.
Dart 3.0 introduced modifiers that limit how a class can be used outside its own library. A library is roughly a file; the restrictions apply to use from other files, not inside the same file:
| Modifier | Can be instantiated | extends | implements | Closest C# counterpart |
|---|---|---|---|---|
| (none) | Yes | Yes | Yes | Ordinary class |
abstract | No | Yes | Yes | abstract class |
interface | Yes | No | Yes | Interface + default implementation |
base | Yes | Yes | No | A base class that cannot be implemented |
final | Yes | No | No | sealed class (in the C# sense) |
sealed | No | No | No | C# 15 closed hierarchy |
Watch out for the name clash: C#'s sealed means "cannot be derived from", and its Dart counterpart is final. Dart's sealed means "subtypes can be declared only in this library", and it gives the compiler a hierarchy whose subtypes it knows completely. That is the basis of the exhaustiveness check we see shortly.
Mixins, Extension Methods and Extension Types
A mixin is the way to add behaviour without deriving from a base class. By writing StockItem with Auditable we got the log field and the audit method. The closest C# counterpart is default interface implementations, but a C# interface cannot hold state; a mixin can have its own fields. The mixin you see most in Flutter is SingleTickerProviderStateMixin: you add ticker support to a State class that uses animations this way.
Extension methods are the same idea as in C#: adding methods to an existing type without touching its source. The syntax differs: in C# you write a static method with a this parameter, in Dart you open an extension on DateTime { ... } block. An unnamed extension is visible only in the file where it is declared.
Extension types, introduced in Dart 3.3, have no direct counterpart in C#. Money is a separate type at compile time: you cannot pass a Money where an int is expected or a plain int where a Money is expected, and only the operators you declared are available. At run time, however, there is no wrapper object at all; at run time: int, is int: true in the output shows this. In C# you would write a readonly record struct Money(long Kurus) for this; an extension type gives the same type safety at zero memory cost. That is also its price: the type does not exist at run time, and an is Money check is no different from is int.
Sealed Classes and Pattern Matching: OrderStatus
We modelled the order status in bookstore_app with a sealed hierarchy:
sealed class OrderStatus {
const OrderStatus();
}
final class Pending extends OrderStatus {
const Pending();
}
final class Shipped extends OrderStatus {
const Shipped({required this.trackingNumber, required this.shippedAt});
final String trackingNumber;
final DateTime shippedAt;
}
final class Delivered extends OrderStatus {
const Delivered({required this.deliveredAt});
final DateTime deliveredAt;
}
final class Cancelled extends OrderStatus {
const Cancelled({this.reason});
final String? reason;
}
/// Label shown in the order history; the switch must cover every subtype.
String statusLabel(OrderStatus status, {required DateTime now}) =>
switch (status) {
Pending() => 'Waiting for payment',
Shipped(:final trackingNumber, :final shippedAt)
when now.difference(shippedAt).inDays > 7 =>
'Delayed in shipping ($trackingNumber)',
Shipped(:final trackingNumber) => 'On the way ($trackingNumber)',
Delivered(:final deliveredAt) =>
'Delivered on ${deliveredAt.toIso8601String().substring(0, 10)}',
Cancelled(reason: null) => 'Cancelled',
Cancelled(:final reason?) => 'Cancelled: $reason',
};
Here switch is an expression (a switch expression), familiar from C# 8. Each line is a pattern: the object pattern Shipped(:final trackingNumber) both checks the type and unpacks the getter into a variable with the same name. The when guard is the same as C#'s when; it shows a shipment that has been on the way for more than seven days with a separate label. Cancelled(reason: null) matches a constant value, while :final reason? captures a non-null value and binds reason as a String. All six cases in the test produced the expected label:
00:00 +1: test/domain/models/order_status_test.dart: each status has a label
Now the second question from "Guess First". We added final class Returned extends OrderStatus to the same file and did not touch the switch:
error - lib/order_status.dart:31:5 - The type 'OrderStatus' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Returned()'. Try adding a wildcard pattern or cases that match 'Returned()'. - non_exhaustive_switch_expression
The compiler notices, and the code does not compile. Because OrderStatus is sealed, the compiler knows all its subtypes and names the one that is missing. This requires the switch not to have a default branch such as _ =>; if you write a default branch, the new status falls into it and the error disappears. In C# we got the same guarantee with C# 15's union types and closed hierarchies (the post "C# 15 Union Types and Closed Hierarchies"); in earlier C# versions the compiler only gave a warning.
Dot Shorthands and the New Book
We moved the int price from the previous post to the Money extension type and added the book's format as an enum:
/// Amount in kurus (1 TRY = 100 kurus). At run time this is a plain int.
extension type const Money(int kurus) {
static const zero = Money(0);
Money operator +(Money other) => Money(kurus + other.kurus);
Money operator *(int quantity) => Money(kurus * quantity);
String format() =>
'${kurus ~/ 100}.${(kurus % 100).toString().padLeft(2, '0')} TRY';
}
enum BookFormat { paperback, hardcover, ebook }
class Book {
const Book({
required this.isbn,
required this.title,
required this.author,
required this.price,
required this.publishedYear,
this.subtitle,
this.coverUrl,
this.format = .paperback,
});
final String isbn;
final String title;
final String? subtitle;
final Author author;
final Money price;
final int publishedYear;
final String? coverUrl;
final BookFormat format;
String get displayTitle => subtitle == null ? title : '$title: $subtitle';
bool get shipsPhysically => switch (format) {
.paperback || .hardcover => true,
.ebook => false,
};
// A book is identified by its ISBN, whatever the other fields say
@override
bool operator ==(Object other) => other is Book && other.isbn == isbn;
@override
int get hashCode => isbn.hashCode;
}
.paperback and .ebook are Dart 3.10's dot shorthands. When the expected type is known (the default value of a parameter of type BookFormat, or a switch over a BookFormat), you do not need to write the BookFormat. prefix. In Flutter we will use this most for enum parameters such as mainAxisAlignment: .center. .paperback || .hardcover is a logical-or pattern that combines two patterns with "or".
== and hashCode: Value Equality Does Not Come for Free
The two overrides at the end of Book are the answer to the first question in "Guess First". In dart_samples we put two classes side by side:
// Pitfall: == compares identity unless you override it (post 04).
class PlainBook {
PlainBook(this.isbn, this.title);
final String isbn;
final String title;
}
class Book {
Book(this.isbn, this.title);
final String isbn;
final String title;
// Two Book objects are the same book when the ISBN matches
@override
bool operator ==(Object other) => other is Book && other.isbn == isbn;
@override
int get hashCode => isbn.hashCode;
}
void main() {
// The same book arrives twice, e.g. from two API calls
final a = PlainBook('9780441172719', 'Dune');
final b = PlainBook('9780441172719', 'Dune');
print(
'PlainBook a == b: ${a == b}, set size: ${{a, b}.length}, contains: ${[a].contains(b)}',
);
final c = Book('9780441172719', 'Dune');
final d = Book('9780441172719', 'Dune');
print(
'Book c == d: ${c == d}, set size: ${{c, d}.length}, contains: ${[c].contains(d)}',
);
// Records compare by value without any extra code
print(
'record: ${(isbn: '9780441172719', title: 'Dune') == (isbn: '9780441172719', title: 'Dune')}',
);
}
PlainBook a == b: false, set size: 2, contains: false
Book c == d: true, set size: 1, contains: true
record: true
A == that is not overridden compares references, as with classes in C#; Dune stays twice in the set and contains cannot find it. In C#, when you write a record, the compiler generates Equals and GetHashCode; Dart classes have no such shortcut, so you either write them yourself or use a code-generating package (we look at freezed in the API post). Overriding == and forgetting hashCode is an even sneakier bug: == returns true, but because Set and Map look at the hash first, they still keep the two objects apart.
We made a deliberate choice for Book: equality by ISBN, not by all fields. A book is an entity in the domain; it is the same book even if its price changes. The test states this explicitly: Dune's 40th anniversary edition, arriving with a different title and price, equals the Dune in the catalog and stays as a single entry in a set.
00:00 +4: test/domain/models/book_test.dart: books are equal when the ISBN matches
00:00 +5: test/domain/models/book_test.dart: only ebooks skip shipping
00:00 +6: test/widget_test.dart: tapping the button adds a book to the cart
00:00 +7: All tests passed!
In Production
Do not make a habit of adding a default branch (_ =>) to switches over sealed hierarchies. The value of the exhaustiveness check is that the compiler takes you to every place of use when a new subtype is added; a default branch turns that check off. To guard against an unknown status coming from the API, declare an explicit UnknownStatus subtype where the model is created from JSON.
Extension types give no protection at the API boundary. Wrapping an int from JSON in Money(...) gives type safety at compile time, but when you do as Money on a dynamic value at run time, no check happens. Do the conversion in one place, explicitly.
Primary constructors need Dart 3.13. If you write a shared package, using this syntax raises the SDK lower bound in pubspec.yaml to 3.13 and keeps users on older Flutter versions from getting the package. In app code it is not a problem; in library code it is a decision.
Common Pitfalls
The first pitfall is trying to write constructor overloads as in C#. A second constructor with the same name does not compile in Dart; you use named constructors (Book.fromJson, Category.uncategorized) or optional named parameters.
The second pitfall is trying to assign a final field in the constructor body. In Dart a final field is assigned in its declaration, in the initializer list or through a this.x parameter; by the time the body runs, the object is already set up.
The third pitfall is taking Dart's sealed for C#'s sealed. "Cannot be derived from" in the C# sense is final in Dart; Dart's sealed builds a closed hierarchy for exhaustiveness, and you cannot create instances of it.
The fourth pitfall is overriding == and forgetting hashCode, or computing hashCode from a field that can change. If an object's hash changes after it has been put into a set, the set can never find it again.
A Small Experiment
★☆☆ Add the Returned subtype to OrderStatus, write a label where the compiler points, and add this case to order_status_test.dart.
★★☆ Add operator - and an isNegative getter to Money. Then try writing Money price = 48900;: what does the compiler say? What would happen with the same line in C# with readonly record struct Money(long Kurus)?
★★★ Turn Category into a primary constructor. Where do you have to put the trim and slug calculation while keeping the name and slug fields? Write the uncategorized and cached constructors with the new syntax (new uncategorized(), factory cached(...)) and verify the output does not change.
In the next post we move to Dart's asynchronous model: the single-threaded event loop, the differences between Future and Task, Stream and IAsyncEnumerable, Isolate.run for parsing a large JSON without freezing the UI, and how the error of a Future that nobody awaits gets lost.