Dart 3 Enhancements — Sealed Classes, Records, Pattern Matching & the Features Most Flutter Devs Skip
Dart 3 shipped with features that fundamentally changed how you model data and write control flow. Most Flutter developers are still writing Dart 2-style code. This week fixes that — with real-world depth.
Why most developers pretend Dart 3 didn’t happen
Dart 3 launched with Flutter 3.10 in May 2023. It brought sealed classes, records, patterns, and destructuring — features that Kotlin and Swift developers have had for years. But two years later, most Flutter developers still haven’t adopted them. And then an interviewer asks:
Interview Question
“What is a sealed class in Dart 3? How does it differ from abstract? What is a record type, and how does pattern matching with switch expressions work? Show me how you’d model a network response state using these features.”
This is a legitimately hard question if you’ve been writing Dart 2 code. The answer touches sealed classes, exhaustive switch, records, destructuring, and guard clauses — all in one scenario. That’s exactly what we’ll build by the end of this post.
1. The Five Dart 3 Features You Need to Own
2. Sealed Classes — Exhaustive Pattern Matching
A sealed class is like an abstract class with one critical addition: the compiler knows every possible subtype, because they must all live in the same library file. This enables exhaustive switch — you get a compile error if you forget to handle a case.
Mental Model
Think of a sealed class as an enum on steroids. A regular enum holds a value. A sealed class hierarchy holds a value with data attached. Loading has no data. Success has the result. Error has a message. The compiler enforces that you handle every possibility — just like a good enum should.
// dart 3 — sealed class for network state
// ALL subtypes must be in the same file
sealed class NetworkState<T> {}
class Loading<T> extends NetworkState<T> {}
class Success<T> extends NetworkState<T> {
final T data;
const Success(this.data);
}
class Failure<T> extends NetworkState<T> {
final String message;
final int? statusCode;
const Failure(this.message, {this.statusCode});
}
// Switch EXPRESSION — returns a value, no default needed!
String describe<T>(NetworkState<T> state) => switch (state) {
Loading() => 'Fetching data...',
Success(:final data) => 'Got: $data',
Failure(:final message, :final statusCode) =>
'Error ${statusCode ?? '?'}: $message',
};
// no default — compiler GUARANTEES all cases covered
void main() {
List<NetworkState<String>> states = [
Loading(),
Success('User data'),
Failure('Not found', statusCode: 404),
Failure('Timeout'),
];
states.forEach((s) => print(describe(s)));
}▶ Output
Fetching data...
Got: User data
Error 404: Not found Error ?: TimeoutWhy sealed + switch is so powerful
Add a fourth subclass — say
class Empty<T> extends NetworkState<T> {}— and every switch expression onNetworkStatethroughout your codebase will immediately give a compile error telling you exactly where you forgot to handle it. With abstract classes, you'd get a silent runtime error.
sealed vs abstract — the exact difference
// dart — sealed vs abstract: compile-time guarantee
// abstract class — switch has NO compile error if you miss a case
abstract class Shape {}
class Circle extends Shape {
final double r;
Circle(this.r);
}
class Rectangle extends Shape {
final double w, h;
Rectangle(this.w, this.h);
}
double areaAbstract(Shape s) {
return switch (s) {
Circle() => 3.14 * (s as Circle).r * (s as Circle).r,
Rectangle() => (s as Rectangle).w * (s as Rectangle).h,
_ => 0, // MUST have default — compiler doesn't know all subtypes
};
}
// sealed class — switch is EXHAUSTIVE, no default needed
sealed class SealedShape {}
class SCircle extends SealedShape {
final double r;
SCircle(this.r);
}
class SRectangle extends SealedShape {
final double w, h;
SRectangle(this.w, this.h);
}
double areaSealed(SealedShape s) => switch (s) {
SCircle(:final r) => 3.14 * r * r, // destructured!
SRectangle(:final w, :final h) => w * h, // destructured!
// no default needed — compiler knows exactly 2 subtypes
};
void main() {
print(areaSealed(SCircle(5))); // 78.5
print(areaSealed(SRectangle(4, 6))); // 24.0
}▶ Output
78.5
24.03. Records Dart 3 New — Lightweight Value Bundles
A record is a lightweight, anonymous, immutable collection of values. Think of it as a named tuple. Before records, returning multiple values from a function meant creating a class or using a List/Map. Now you just use a record.
// dart 3 — records: creating, accessing, named fields
void main() {
// Positional record — access with $1, $2
var person = ('Arjun', 28); // (String, int)
print(person.$1); // Arjun
print(person.$2); // 28
// Named fields — access by name
var user = (name: 'Priya', age: 25); // (String name, int age)
print(user.name); // Priya
print(user.age); // 25
// Mixed — positional + named
var measurement = (98.6, unit: '°F'); // (double, {String unit})
print(measurement.$1); // 98.6
print(measurement.unit); // °F
// Records are VALUE types — equality by content
var r1 = (1, 2, 'hello');
var r2 = (1, 2, 'hello');
print(r1 == r2); // true — content equality!
print(identical(r1, r2)); // false — different objects but equal
// Type inference — Dart infers the record type
var coord = (40.7128, -74.0060); // inferred as (double, double)
print(coord.runtimeType); // (double, double)
}▶ Output
Arjun
28
Priya
25
98.6
°F
true
false
(double, double)Records as function return types — the killer use case
// dart 3 — return multiple values cleanly
// Before records — awkward workarounds
Map<String, dynamic> parseUserOld(String raw) {
final parts = raw.split(':');
return {'name': parts[0], 'age': int.parse(parts[1])};
}
// With records — clean, typed, no class needed
({String name, int age}) parseUser(String raw) {
final parts = raw.split(':');
return (name: parts[0], age: int.parse(parts[1]));
}
// Real-world: validate with multiple results
({bool isValid, List<String> errors, String? sanitised}) validateEmail(
String input,
) {
final errors = <String>[];
final trimmed = input.trim().toLowerCase();
if (!trimmed.contains('@')) errors.add('Missing @');
if (!trimmed.contains('.')) errors.add('Missing domain');
if (trimmed.length < 5) errors.add('Too short');
return (
isValid: errors.isEmpty,
errors: errors,
sanitised: errors.isEmpty ? trimmed : null,
);
}
void main() {
final user = parseUser('Arjun:28');
print('${user.name} is ${user.age}'); // Arjun is 28
final result1 = validateEmail(' ARJUN@GMAIL.COM ');
print(result1.isValid); // true
print(result1.sanitised); // arjun@gmail.com
final result2 = validateEmail('notanemail');
print(result2.isValid); // false
print(result2.errors); // [Missing @, Missing domain]
}▶ Output
Arjun is 28
true
arjun@gmail.com
false
[Missing @, Missing domain]When to use records vs classes
Use a record when the data is short-lived, anonymous, and doesn’t need methods — like a function return value, a temporary grouping, or config pairs. Use a class when the data has behaviour (methods), an identity, or will be passed around extensively. Records can’t have methods or inheritance.
4. Pattern Matching Dart 3 New — The Full Picture
Pattern matching in Dart 3 is much deeper than just “a nicer switch.” Patterns work in switch expressions, switch statements, if-case, and variable declarations. There are 13 pattern types in Dart 3 — we'll cover the ones that matter most.
Type patterns — replace is-checks and casts
// dart 3 — type patterns and variable binding
void main() {
Object value = 'Hello Dart 3';
// Old way — verbose, requires explicit cast
if (value is String) {
print(value.toUpperCase()); // HELLO DART 3
}
// New way — if-case with pattern binding
if (value case String s) {
print(s.toUpperCase()); // HELLO DART 3 — s is bound here
}
// Switch with type patterns + binding
Object data = [1, 2, 3];
switch (data) {
case String s:
print('String: ${s.length}');
case List<int> list when list.isNotEmpty:
print('Non-empty int list: ${list.length} items'); // ← hits this case
case List l:
print('Other list: ${l.length}');
default:
print('Unknown');
}
// Logical-or pattern — multiple types in one case
for (final v in [1, 2.5, 'hi', true]) {
final label = switch (v) {
int() || double() => 'number', // matches int OR double
String() => 'text',
_ => 'other',
};
print('$v is $label');
}
}▶ Output
HELLO DART 3
HELLO DART 3
Non-empty int list: 3 items
1 is number
2.5 is number
hi is text
true is otherValue patterns — match exact values
// dart 3 — constant and relational patterns
void main() {
// Constant patterns — match specific values
String httpStatus(int code) => switch (code) {
200 => 'OK',
201 => 'Created',
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
500 => 'Server Error',
final c when c >= 200 && c < 300 => 'Other Success',
final c when c >= 400 => 'Client/Server Error $c',
_ => 'Unknown $code',
};
print(httpStatus(200)); // OK
print(httpStatus(201)); // Created
print(httpStatus(204)); // Other Success
print(httpStatus(422)); // Client/Server Error 422
print(httpStatus(503)); // Client/Server Error 503
// Null patterns — handle nullable elegantly
String? username;
final display = switch (username) {
null => 'Guest',
final u => 'Hi, $u',
};
print(display); // Guest
}▶ Output
OK
Created
Other Success
Client/Server Error 422
Client/Server Error 503
Guest5. Destructuring Dart 3 New — Extract Values Directly
Destructuring lets you unpack the fields of a record, list, map, or object directly into variables in a single expression. It works in variable declarations, for loops, switch cases, and if-case.
// dart 3 — destructuring records, lists, maps, and objects
void main() {
// ── RECORD DESTRUCTURING ─────────────────────────────────
final point = (3.0, 4.0);
final (x, y) = point; // destructure into x and y
print('x=$x, y=$y'); // x=3.0, y=4.0
// Named record destructuring
final user = (name: 'Arjun', age: 28, city: 'Kozhikode');
final (:name, :age, city: hometown) = user;
print('$name, $age, from $hometown'); // Arjun, 28, from Kozhikode
// ── LIST DESTRUCTURING ───────────────────────────────────
final [first, second, ...rest] = [1, 2, 3, 4, 5];
print(first); // 1
print(second); // 2
print(rest); // [3, 4, 5]
// ── MAP DESTRUCTURING ────────────────────────────────────
final json = {'id': 1, 'name': 'Arjun', 'role': 'dev'};
final {'id': id, 'name': userName} = json;
print('$id: $userName'); // 1: Arjun
// ── OBJECT DESTRUCTURING ─────────────────────────────────
final now = DateTime.now();
final DateTime(:year, :month, :day) = now;
print('$year-$month-$day'); // e.g. 2025-11-15
// ── IN FOR LOOPS ─────────────────────────────────────────
List<(String, int)> scores = [('Alice', 95), ('Bob', 88), ('Charlie', 92)];
for (final (name, score) in scores) {
print('$name: $score');
}
}▶ Output
x=3.0, y=4.0
Arjun, 28, from Kozhikode
1
2
[3, 4, 5]
1: Arjun
2025-11-15
Alice: 95
Bob: 88
Charlie: 926. Switch Expressions & Guard Clauses Dart 3 Enhanced
Dart 3’s switch expression (not statement) returns a value — it’s an expression you can assign or return directly. Guard clauses with when add extra conditions to a case beyond just type/value matching.
// dart 3 - switch expressions vs statements, guard clauses
void main() {
// Switch STATEMENT — old style, side effects only
var status = 404;
switch (status) {
case 200:
print('OK');
break;
case 404:
print('Gone');
break;
default:
print('?');
}
// Switch EXPRESSION — returns a value, assign directly
final message = switch (status) {
200 => 'OK',
404 => 'Not Found',
500 => 'Server Error',
_ => 'Unknown',
};
print(message); // Not Found
// Guard clauses — 'when' adds extra conditions
List<Object> items = [42, 'hello', -5, 100, 'world', 0];
for (final item in items) {
final label = switch (item) {
int n when n > 50 => 'big number ($n)', // int AND > 50
int n when n > 0 => 'small positive ($n)', // int AND > 0
int n => 'non-positive ($n)',
String s when s.length > 4 => 'long string: $s',
String s => 'short string: $s',
_ => 'unknown',
};
print(label);
}
}▶ Output
Gone
Not Found
small positive (42)
short string: hello
non-positive (-5)
big number (100)
long string: world
non-positive (0)7. Real-World Example — API Response Pipeline Using All Five Features
Let’s combine sealed classes, records, pattern matching, destructuring, and switch expressions into a production-quality API response handler — the exact scenario interviewers test.
// dart 3 - complete API response pipeline
// ── 1. SEALED CLASS for state modelling ──────────────────
sealed class ApiResponse<T> {}
class ApiLoading<T> extends ApiResponse<T> {
const ApiLoading();
}
class ApiSuccess<T> extends ApiResponse<T> {
final T data;
final Duration elapsed;
const ApiSuccess(this.data, this.elapsed);
}
class ApiError<T> extends ApiResponse<T> {
final String message;
final int statusCode;
const ApiError(this.message, this.statusCode);
}
// ── 2. RECORD for returning metadata alongside result ────
({String endpoint, DateTime timestamp}) buildMeta(String path) =>
(endpoint: path, timestamp: DateTime.now());
// ── 3. PATTERN MATCHING + SWITCH EXPRESSION to handle state
String renderUI<T>(ApiResponse<T> response) => switch (response) {
ApiLoading() => '⏳ Loading...',
ApiSuccess(:final data, :final elapsed) when elapsed.inMilliseconds < 500 =>
'⚡ Fast (${elapsed.inMilliseconds}ms): $data',
ApiSuccess(:final data, :final elapsed) =>
'✅ Success (${elapsed.inMilliseconds}ms): $data',
ApiError(:final statusCode) when statusCode == 401 => '🔐 Please log in again',
ApiError(:final message, :final statusCode) =>
'❌ Error $statusCode: $message',
};
// ── 4. DESTRUCTURING in processing logic ─────────────────
void processResponses(List<ApiResponse<String>> responses) {
for (final (index, response) in responses.indexed) {
print('[$index] ${renderUI(response)}');
}
// Count successes using pattern in where()
final successes = responses.whereType<ApiSuccess<String>>().length;
final errors = responses.whereType<ApiError<String>>().length;
print('Summary: $successes ok, $errors failed');
}
void main() {
final responses = <ApiResponse<String>>[
const ApiLoading(),
ApiSuccess('User data', Duration(milliseconds: 180)),
ApiSuccess('Product list', Duration(milliseconds: 620)),
const ApiError('Unauthorized', 401),
const ApiError('Not found', 404),
];
processResponses(responses);
// Record destructuring for metadata
final (:endpoint, :timestamp) = buildMeta('/api/v1/users');
print('Called $endpoint at ${timestamp.toIso8601String()}');
}▶ Output
[0] ⏳ Loading...
[1] ⚡ Fast (180ms): User data
[2] ✅ Success (620ms): Product list
[3] 🔐 Please log in again
[4] ❌ Error 404: Not found
Summary: 2 ok, 2 failed
Called /api/v1/users at 2025-11-15T10:30:00.0008. Complete Reference — Dart 3 Syntax at a Glance
9. Advanced Pattern Types — The Full Arsenal
Dart 3 has 13 pattern types. Beyond the basics we’ve seen, these three are the most useful in real codebases — and they’re the ones interviewers reach for when they want to separate candidates who read the release notes from candidates who actually use the features.
Wildcard & rest patterns — ignore what you don’t need
// dart 3 - wildcard, rest, and null-check patterns
void main() {
// Wildcard _ — explicitly ignore a value
final (_, y, z) = (1, 2, 3);
print('y=$y, z=$z'); // y=2, z=3 — first element ignored
// In switch — wildcard matches anything without binding
Object val = 42;
switch (val) {
case String _:
print('a string');
case int n when n > 0:
print('positive int: $n'); // ← hits this case
case _:
print('other');
}
// Rest pattern ... — capture the "rest" of a list
final [head, ...tail] = [10, 20, 30, 40];
print('head=$head, tail=$tail'); // head=10, tail=[20,30,40]
final [...init, last] = [10, 20, 30, 40];
print('last=$last, init=$init'); // last=40, init=[10,20,30]
final [first2, ..._, last2] = [1, 2, 3, 4, 5];
print('$first2 to $last2'); // 1 to 5 — middle ignored
// Null-check pattern ! — assert non-null within pattern
Map<String, String?> config = {'host': 'localhost', 'port': null};
if (config case {'host': final String host!}) {
print('Host: $host'); // Host: localhost — only if non-null
}
// Logical-and pattern && — combine two patterns, both must match
Object data = 42;
if (data case >= 0 && <= 100) {
print('$data is in range [0,100]'); // 42 is in range [0,100]
}
}▶ Output
y=2, z=3
positive int: 42
head=10, tail=[20, 30, 40]
last=40, init=[10, 20, 30]
1 to 5
Host: localhost
42 is in range [0,100]Nested patterns — matching deep structures
// dart 3 — nested object + record patterns in switch
class Address {
final String city;
final String country;
const Address(this.city, this.country);
}
class User {
final String name;
final int age;
final Address address;
const User(this.name, this.age, this.address);
}
void main() {
final users = [
const User('Arjun', 28, Address('Kozhikode', 'India')),
const User('Alice', 17, Address('London', 'UK')),
const User('Bob', 35, Address('Mumbai', 'India')),
];
for (final user in users) {
// Nested object pattern — destructure User AND Address
final label = switch (user) {
User(age: < 18, name: final n) => 'Minor: $n',
User(
name: final n,
address: Address(country: 'India', city: final c),
) =>
'Indian dev $n from $c',
User(name: final n, address: Address(country: final ctry)) =>
'International: $n ($ctry)',
};
print(label);
}
}▶ Output
Indian dev Arjun from Kozhikode
Minor: Alice
Indian dev Bob from MumbaiPattern Nesting Depth
Patterns compose arbitrarily deep. You can match a
Userwhoseaddress.countryis'India'AND whoseageis over 18 in a single case arm — no nestedifstatements needed. This is the expressiveness that makes Dart 3 patterns so powerful for domain modelling.
10. Records Deep Dive — Equality, Spread, and Practical Patterns
Records look simple but have some nuances that matter in production code — especially around equality semantics, how they work with generics, and common patterns like swapping variables.
// dart 3 — record equality, swap, and generic records
void main() {
// Value equality — no override needed, built-in
var p1 = (x: 1, y: 2);
var p2 = (x: 1, y: 2);
var p3 = (x: 1, y: 3);
print(p1 == p2); // true — same field names AND values
print(p1 == p3); // false — y differs
// Named vs positional are DIFFERENT types!
var pos = (1, 2); // positional
var named = (x: 1, y: 2); // named
// pos == named → compile error: different record types
// SWAP trick — clean one-liner
int a = 10;
int b = 20;
(a, b) = (b, a);
print('a=$a, b=$b'); // a=20, b=10
// Records with generics (T, T)
(T, T) pair<T>(T a, T b) => (a, b);
final (min, max) = pair(3, 7);
print('min=$min, max=$max'); // min=3, max=7
// Records in a list — type-safe pairs
List<(String, double)> prices = [
('Apple', 1.20),
('Banana', 0.50),
('Mango', 2.30),
];
// Destructure in a pipeline
final total = prices
.where(((name, price)) => price > 0.75) // pattern in lambda
.map(((name, price)) => price)
.fold(0.0, (sum, p) => sum + p);
print(total.toStringAsFixed(2)); // 3.50 (apple + mango)
// Record as Map key — value equality makes this work!
Map<(int, int), String> grid = {};
grid[(0, 0)] = 'origin';
grid[(1, 0)] = 'right';
print(grid[(0, 0)]); // origin — works because records have value hashCode
}▶ Output
true
false
a=20,
b=10
min=3,
max=7
3.50
originRecord Limitations to Know
Records cannot have methods (only getters via positional
$1or named fields). They cannot extend or implement anything. They cannot be subclassed. They are always immutable — all fields are final by nature. If you need any of these, use a class instead. Records are intentionally minimal — that's what makes them fast and safe.
11. Dart 3 in Flutter — Real Widget Patterns
These Dart 3 features aren’t just abstract language improvements — they directly improve how you write Flutter code. Here are the four patterns you’ll use constantly once you adopt them.
// dart 3 - sealed + switch in Flutter widget build method
// ── PATTERN 1: Sealed state in BLoC/Riverpod ─────────────
sealed class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
final String name;
UserLoaded(this.name);
}
class UserError extends UserState {
final String msg;
UserError(this.msg);
}
// Widget build — exhaustive, no default, compiler-safe
// Widget buildUserWidget(UserState state) =>
// switch (state) {
// UserInitial() => const SizedBox.shrink(),
// UserLoading() => const CircularProgressIndicator(),
// UserLoaded(:final name) => Text('Hello, $name'),
// UserError(:final msg) => Text(msg, style: errorStyle),
// };
// ← add UserOffline and EVERY switch breaks — caught at compile time
// ── PATTERN 2: Record for theme data pairs ───────────────
typedef ColorPair = (String light, String dark);
ColorPair getThemeColors(String brand) => switch (brand) {
'primary' => ('#185FA5', '#85B7EB'),
'success' => ('#3B6D11', '#5DCAA5'),
'danger' => ('#A32D2D', '#F09595'),
_ => ('#444444', '#AAAAAA'),
};
void main() {
final (light, dark) = getThemeColors('primary');
print('Light: $light, Dark: $dark');
// ── PATTERN 3: if-case for safe JSON extraction ──────────
Map<String, dynamic> json = {
'user': {'name': 'Arjun', 'age': 28},
};
if (json['user'] case {'name': String name, 'age': int age}) {
print('$name is $age'); // Arjun is 28
// Only runs if BOTH keys exist AND have the right types
}
// ── PATTERN 4: switch expression for routing ─────────────
String routeTitle(String path) => switch (path) {
'/' => 'Home',
'/profile' => 'My Profile',
'/settings' => 'Settings',
final p when p.startsWith('/product/') => 'Product Details',
final p when p.startsWith('/user/') => 'User Profile',
_ => 'Not Found',
};
['/', '/profile', '/product/42', '/unknown'].forEach(
(p) => print('$p → ${routeTitle(p)}'),
);
}▶ Output
Light: #185FA5, Dark: #85B7EB
Arjun is 28
/ → Home
/profile → My Profile
/product/42 → Product Details
/unknown → Not Found12. Full Example — Dart 3 State Machine for a Shopping Cart
Let’s build something complex enough to feel real: a shopping cart state machine using sealed classes, records for cart items, pattern matching for business rules, and exhaustive switch for UI rendering. This is the kind of code senior devs write.
// dart 3 - shopping cart state machine
// ── RECORD: cart item is a lightweight value type ────────
typedef CartItem = ({String id, String name, double price, int qty});
// ── SEALED: cart state hierarchy ─────────────────────────
sealed class CartState {}
class CartEmpty extends CartState {}
class CartActive extends CartState {
final List<CartItem> items;
const CartActive(this.items);
double get subtotal => items.fold(0, (s, i) => s + i.price * i.qty);
int get itemCount => items.fold(0, (s, i) => s + i.qty);
}
class CartCheckingOut extends CartState {
final List<CartItem> items;
final String promoCode;
CartCheckingOut(this.items, this.promoCode);
}
class CartOrdered extends CartState {
final String orderId;
final double total;
const CartOrdered(this.orderId, this.total);
}
// ── BUSINESS LOGIC: pricing rules via pattern matching ────
double calculateDiscount(CartCheckingOut state) => switch (state) {
// promo code + big order → 25% off
CartCheckingOut(promoCode: 'SAVE25', items: final i)
when i.fold(0.0, (s, x) => s + x.price * x.qty) > 1000 =>
0.25,
// any promo code → 10% off
CartCheckingOut(promoCode: final p) when p.isNotEmpty => 0.10,
// no promo → 0%
_ => 0.0,
};
// ── UI RENDERING: exhaustive switch expression ────────────
String renderCart(CartState state) => switch (state) {
CartEmpty() => '🛒 Your cart is empty. Start shopping!',
CartActive(:final items, :final subtotal, :final itemCount) =>
'🛍 $itemCount item(s) — Subtotal: ₹${subtotal.toStringAsFixed(2)}',
CartCheckingOut(:final items, :final promoCode) => () {
final sub = items.fold(0.0, (s, i) => s + i.price * i.qty);
final discount = sub * calculateDiscount(CartCheckingOut(items, promoCode));
return '💳 Checkout — ₹${(sub - discount).toStringAsFixed(2)}'
' (saved ₹${discount.toStringAsFixed(2)} with $promoCode)';
}(),
CartOrdered(:final orderId, :final total) =>
'✅ Order #$orderId confirmed! Total: ₹${total.toStringAsFixed(2)}',
};
void main() {
final items = <CartItem>[
(id: 'p1', name: 'Dart Book', price: 499, qty: 2),
(id: 'p2', name: 'Flutter Course', price: 1299, qty: 1),
];
List<CartState> journey = [
CartEmpty(),
CartActive(items),
CartCheckingOut(items, 'SAVE25'),
const CartOrdered('ORD-2025-001', 1716.75),
];
journey.forEach((s) => print(renderCart(s)));
// Destructure CartActive for summary
if (journey[1] case CartActive(:final items)) {
for (final (:name, :price, :qty, id: _) in items) {
print(' $name × $qty = ₹${(price * qty).toStringAsFixed(2)}');
}
}
}▶ Output
🛒 Your cart is empty. Start shopping!
🛍 3 item(s) — Subtotal: ₹2297.00
💳 Checkout — ₹1722.75 (saved ₹574.25 with SAVE25)
✅ Order #ORD-2025-001 confirmed! Total: ₹1716.75
Dart Book × 2 = ₹998.00
Flutter Course × 1 = ₹1299.00Why this approach is production-grade
When the product team adds a
CartAbandonedstate, everyswitch (state)in the codebase immediately shows a compile error — including the UI renderer, analytics handler, and notification system. Zero chance of a silent bug. This is the safety guarantee sealed classes provide that no other OOP approach can match.
13. Dart 2 vs Dart 3 — Side-by-Side
The fastest way to internalise these improvements is to see the same problem solved both ways. Here’s the before-and-after on three common Flutter patterns.
// dart 2 vs dart 3 - three patterns compared
// ═══ PATTERN 1: Multi-value return ═══════════════════════
// Dart 2 — awkward Map or custom class
Map<String, dynamic> minMaxOld(List<int> nums) => {
'min': nums.reduce((a, b) => a < b ? a : b),
'max': nums.reduce((a, b) => a > b ? a : b),
};
// Dart 3 — clean record
(int min, int max) minMax(List<int> nums) => (
nums.reduce((a, b) => a < b ? a : b),
nums.reduce((a, b) => a > b ? a : b),
);
// ═══ PATTERN 2: State handling ═══════════════════════════
// Dart 2 — manual type check + cast
String handleOld(Object state) {
if (state is Loading) return 'Loading';
if (state is Success) {
final s = state as Success;
return 'Got ${s.data}';
}
if (state is Failure) {
final f = state as Failure;
return 'Error: ${f.message}';
}
return 'Unknown'; // silent gap — no compiler help
}
// Dart 3 — sealed + switch expression + destructuring
String handle(NetworkState<String> state) => switch (state) {
Loading() => 'Loading',
Success(:final data) => 'Got $data',
Failure(:final message) => 'Error: $message',
}; // no default — exhaustive guaranteed
// ═══ PATTERN 3: Variable swap ════════════════════════════
void main() {
int x = 5;
int y = 10;
// Dart 2 — need a temp variable
int temp = x;
x = y;
y = temp;
print('$x, $y'); // 10, 5
// Dart 3 — destructuring swap
(x, y) = (y, x);
print('$x, $y'); // 5, 10
// minMax with destructuring
final (min, max) = minMax([5, 2, 9, 1, 7]);
print('min=$min, max=$max'); // min=1, max=9
}▶ Output
10, 5
5, 10
min=1, max=914. What Interviewers Actually Want
🎯 Common Interview Questions on Dart 3 Features
Q1 What is a sealed class and how does it differ from abstract?
Get Jiyad Ahammad’s stories in your inbox
Join Medium for free to get updates from this writer.
A sealed class restricts all direct subtypes to the same library file. The compiler therefore knows every possible subtype — enabling exhaustive switch without a default case. If you add a new subtype, every switch on that sealed class will immediately show a compile error. Abstract classes have no such restriction — any file can subclass them, so the compiler can't know all cases.
Q2 What is a record in Dart 3? When would you use one over a class?
A record is a lightweight, anonymous, immutable value type that bundles multiple values. Records have structural equality (same content = equal). Use records for short-lived groupings like function return values, temporary configurations, or loop variables. Use classes when you need methods, inheritance, identity, or when the type will be widely used — classes are named and have constructors.
Q3 What is the difference between a switch statement and a switch expression?
A switch statement (old style) executes side effects — uses case X: ...; break; syntax, requires default for non-sealed types. A switch expression (Dart 3) produces a value — uses X => value syntax, can be assigned or returned directly, and with sealed types is exhaustive without a default. Switch expressions are significantly more concise for mapping values.
Q4 What are guard clauses in switch patterns?
A guard clause is an extra condition on a switch case added with the when keyword: case int n when n > 0. The case only matches if both the pattern AND the guard evaluate to true. Guards let you add range checks, method calls, or complex conditions beyond what the pattern itself can express — keeping all the matching logic inline rather than using nested ifs.
Q5 How does destructuring work in Dart 3? Give a practical example.
Destructuring extracts fields from records, lists, maps, or objects into named variables in a single expression. Works in variable declarations (final (x, y) = point), for loops (for final (a, b) in pairs), switch cases (case ApiSuccess(:final data)), and if-case. Object destructuring extracts getters by name: final DateTime(:year, :month) = now.
Q6 Model a three-state async operation (loading/success/error) using Dart 3 features.
Use a sealed class with three subtypes: Loading, Success<T> (with data), Failure (with message + statusCode). Handle it with a switch expression — no default needed. Add guard clauses for sub-cases (e.g. 401 treated differently from other errors). Destructure the data fields inline in the case: ApiSuccess(:final data, :final elapsed).
Conclusion — Dart 3 Changes How You Think About Code
Dart 3 isn’t just new syntax — it’s a different way of thinking about data modelling and control flow. The old approach was imperative: check the type, cast it, then use it. The Dart 3 approach is declarative: describe the shape of data and let the compiler enforce completeness.
Everything you now own:
- Sealed classes — all subtypes in one file, compiler-guaranteed exhaustive switch, safe state modelling
- Records — lightweight anonymous value bundles, structural equality, perfect for multi-value returns
- Switch expressions — return values directly, cleaner than statements, exhaustive with sealed types
- Pattern matching — type patterns, constant patterns, logical-or patterns, null patterns
- Guard clauses —
whenadds runtime conditions on top of patterns - Destructuring — extract fields from records, lists, maps, objects inline in declarations and loops
- if-case — single-pattern check without a full switch when you only care about one shape
- Real-world pattern — sealed + record + switch expression + destructuring + guards all compose together
One-Line Interview Answer — “What’s new in Dart 3 that matters most?”
“The combination of sealed classes and exhaustive switch expressions. You model a finite set of states as a sealed hierarchy, and the compiler guarantees you’ve handled every case — failing at compile time when you miss one. Add records for lightweight multi-value returns and destructuring to extract data inline, and you write significantly less boilerplate than Dart 2.”
One-Line Answer — “What’s a record and when to use one?”
“A record is a lightweight, anonymous, immutable value type that bundles multiple values. It has structural equality built-in — same fields, same values means equal. Use it instead of a class when you need to return multiple values from a function, create a temporary grouping, or type-safe pairs in a list. Use a class when you need methods, inheritance, or a named public type.”
One-Line Answer — “Sealed class vs abstract?”
“Abstract means any file can subclass it — the compiler doesn’t know all possibilities. Sealed means all direct subtypes must be in the same library — the compiler knows every case and enforces exhaustiveness in switch. Add a new subtype to a sealed class and every switch on it breaks at compile time. With abstract, that’s a silent runtime bug.”










