Skip to main content

Dart 3 Records, Pattern Matching, and Sealed Classes

· 7 min read
Flutter Family
Flutter Family Core Team

Discover how Dart 3 introduces records, pattern matching, and sealed classes to make your code more expressive, type-safe, and maintainable. Learn how these language features simplify complex logic, reduce boilerplate, and enable modern application architecture.


Table of Contents

  • Introduction
  • What's New in Dart 3?
  • Why These Features Matter
  • Understanding Records
  • Named vs Positional Records
  • Record Destructuring
  • Pattern Matching
  • Variable Patterns
  • Object Patterns
  • List Patterns
  • Map Patterns
  • Switch Expressions
  • Exhaustive Pattern Matching
  • Sealed Classes
  • Modeling Application State
  • Combining Records and Patterns
  • Flutter Use Cases
  • Performance Considerations
  • Best Practices
  • Common Mistakes
  • Production Checklist
  • Conclusion

Introduction

Dart has evolved significantly since its early releases, but Dart 3 represents one of the largest language improvements in its history.

Rather than simply adding syntax sugar, Dart 3 introduces features that fundamentally improve how developers model data and express business logic.

The three headline features are:

  • Records
  • Pattern Matching
  • Sealed Classes

Together, they eliminate entire categories of boilerplate while making code easier to read, safer to refactor, and more difficult to misuse.

If you've used Kotlin, Swift, Rust, or modern C#, many of these ideas will feel familiar—but Dart integrates them naturally into Flutter development.


What's New in Dart 3?

Dart 3 introduces several modern language capabilities, including:

  • Records
  • Pattern Matching
  • Switch Expressions
  • Destructuring
  • Sealed Classes
  • Class Modifiers
  • Exhaustive Type Checking

These features work together rather than independently.


Why These Features Matter

Before Dart 3, developers often created tiny model classes simply to return multiple values.

Example:

class UserLocation {
final User user;
final Location location;

UserLocation(this.user, this.location);
}

Or relied on:

Map<String, dynamic>

Neither approach was ideal.

Records eliminate unnecessary classes.

Pattern matching simplifies conditional logic.

Sealed classes improve type safety.

Together they produce cleaner, more maintainable applications.


Understanding Records

Records are immutable objects designed to group multiple values.

Instead of creating a dedicated class:

class Point {
final int x;
final int y;

Point(this.x, this.y);
}

You can simply write:

(int, int)

Returning a record:

(String, int) getUser() {
return ("Alice", 30);
}

Using it:

final user = getUser();

print(user.$1);
print(user.$2);

Named Records

Named fields improve readability.

({String name, int age})

Example:

({String name, int age}) user() {
return (
name: "Alice",
age: 30,
);
}

Access:

final data = user();

print(data.name);
print(data.age);

Named records are generally preferred in production code.


Mixed Records

Records can mix positional and named values.

(int, int, {String label})

Example:

(
120,
240,
label: "Screen"
)

Record Equality

Unlike ordinary objects, records compare by value.

final a = (1, 2);

final b = (1, 2);

print(a == b);

Output:

true

No custom equality implementation is required.


Record Destructuring

Destructuring extracts values directly.

Instead of:

final user = getUser();

final name = user.$1;
final age = user.$2;

Use:

final (name, age) = getUser();

Cleaner.

More expressive.

Less boilerplate.


Named records:

final (
:name,
:age,
) = user();

Pattern Matching

Pattern matching allows values to be inspected and destructured simultaneously.

Example:

switch (shape) {
case Circle(radius: final r):
print(r);

case Rectangle(
width: final w,
height: final h,
):
print(w * h);
}

Notice how matching and variable extraction happen together.


Variable Patterns

Simple destructuring:

final (x, y) = (10, 20);

print(x);
print(y);

No intermediate variables required.


List Patterns

Lists can be matched directly.

switch (numbers) {
case [1, 2]:
print("Two numbers");

case [1, _, _]:
print("Three values");

default:
print("Other");
}

Rest Patterns

Capture remaining values.

switch (numbers) {
case [1, ...rest]:
print(rest);
}

Useful for parsers.


Map Patterns

Pattern matching also supports maps.

switch (json) {
case {
"name": String name,
"age": int age,
}:
print(name);
}

No manual casting.


Object Patterns

Objects become easier to inspect.

switch (user) {
case User(
name: final name,
age: final age,
):
print(name);
}

Switch Expressions

Switch is now an expression.

Instead of:

String color;

switch(status) {
case 200:
color = "green";
break;

default:
color = "red";
}

Use:

final color = switch (status) {
200 => "green",
_ => "red",
};

Less code.

No mutable variables.


Guard Clauses

Patterns support conditions.

switch(user) {
case User(age: >=18):
print("Adult");

case User():
print("Minor");
}

Sealed Classes

One of Dart 3's most powerful additions.

A sealed class restricts inheritance to the current library.

Example:

sealed class LoginState {}

Subclasses:

class Loading extends LoginState {}

class Success extends LoginState {}

class Error extends LoginState {
final String message;

Error(this.message);
}

No external code can create additional subclasses.


Exhaustive Checking

Because the compiler knows every subclass, it ensures every case is handled.

switch(state) {
case Loading():
case Success():
case Error():
}

Forget one?

The compiler warns you.

This eliminates many runtime bugs.


Modeling Application State

Sealed classes are perfect for Flutter state management.

sealed class UserState {}

class Initial extends UserState {}

class Loading extends UserState {}

class Loaded extends UserState {
final User user;

Loaded(this.user);
}

class Error extends UserState {
final String message;

Error(this.message);
}

UI becomes straightforward.

switch(state) {
case Initial():
case Loading():
case Loaded(user: final user):
case Error(message: final message):
}

No nullable fields.

No boolean flags.

No impossible states.


Combining Records and Patterns

Records and patterns complement each other beautifully.

Returning multiple values:

(String, int) user() {
return ("Alice", 30);
}

Destructuring:

final (name, age) = user();

Pattern matching:

switch(user()) {
case ("Alice", final age):
print(age);
}

Elegant and concise.


Flutter Use Cases

These features integrate naturally into Flutter.

Examples include:

  • Authentication states
  • API responses
  • Navigation
  • Form validation
  • Search filters
  • Shopping carts
  • Media players
  • Payment flows

Rather than juggling nullable values, developers can model explicit application states.


Performance Considerations

These language features are designed for efficiency.

Benefits include:

  • Fewer temporary classes
  • Less boilerplate
  • Strong compile-time optimization
  • Better type inference
  • Safer refactoring
  • Reduced runtime checks

In most cases, readability improves without sacrificing performance.


Best Practices

Prefer Named Records

Instead of:

(String, int)

Prefer:

({String name, int age})

Named fields improve readability.


Use Sealed Classes for Finite States

Authentication.

Network requests.

Media playback.

Order processing.

These all have a known number of states.

Model them with sealed classes.


Avoid Large Records

Records should group related values.

If a record contains ten fields, consider creating a dedicated model instead.


Prefer Pattern Matching

Instead of nested:

if

else if

else

Use:

switch

Pattern matching is usually clearer.


Keep Business Logic Immutable

Records are immutable by design.

Combine them with immutable models for safer applications.


Common Mistakes

Using Records as Domain Models

Records are ideal for temporary grouped values.

Business entities should still be represented by dedicated classes.


Overusing Positional Fields

This:

(String, String, int, bool)

is difficult to understand.

Prefer named records.


Ignoring Exhaustiveness

If the compiler warns about a missing case, fix it.

Don't silence the warning.


Creating Deep Pattern Trees

Pattern matching should improve readability—not reduce it.

Break large expressions into smaller functions when appropriate.


Production Checklist

Records

  • ✅ Prefer named records
  • ✅ Use records for temporary grouped values
  • ✅ Avoid oversized records

Pattern Matching

  • ✅ Prefer switch expressions
  • ✅ Use destructuring
  • ✅ Leverage object patterns
  • ✅ Handle all cases

Sealed Classes

  • ✅ Model finite application states
  • ✅ Keep subclasses immutable
  • ✅ Use exhaustive switching

Flutter

  • ✅ Replace boolean state flags
  • ✅ Improve UI state modeling
  • ✅ Reduce nullable properties
  • ✅ Simplify asynchronous workflows

Conclusion

Dart 3 marks a significant milestone in the language's evolution. Records, pattern matching, and sealed classes are more than convenience features—they encourage a more declarative, expressive, and type-safe style of programming.

Records simplify returning and handling multiple values without creating unnecessary model classes. Pattern matching replaces verbose conditional logic with concise, readable expressions that combine type checking and data extraction. Sealed classes enable the compiler to enforce exhaustive handling of finite states, reducing runtime errors and making complex workflows easier to reason about.

For Flutter developers, these features are especially valuable. They lead to cleaner state management, more maintainable architectures, and user interfaces that accurately reflect application state with less boilerplate.

As you adopt Dart 3, look for opportunities to replace ad hoc data structures with records, simplify branching logic through pattern matching, and model finite workflows using sealed classes. The result is code that is easier to read, safer to refactor, and better prepared for long-term growth.