Skip to content
ExponaBLOG
FLUTTER · DAY 043 / 100

Switch expressions in Dart: patterns, pitfalls and fixes

Concise, exhaustive branching.

Expona Admin8 min read

If you've been writing Flutter state management for a while, you've probably felt the pain of nested ternaries or long if-else chains. Switch expressions in Dart transform exhaustive branching from verbose boilerplate into concise, compiler-checked patterns. Today we're exploring how switch expressions make state rendering and complex logic not just cleaner, but safer.

Return values directly from a switch

The traditional switch statement in Dart required you to break out and assign values separately. With switch expressions, you return a value directly inline. This matters more than it sounds—it shifts your mental model from "control flow" to "expression evaluation."

Here's the difference:

dart
// Old style: imperative, requires a separate variable
String statusMessage;
switch (userRole) {
  case 'admin':
    statusMessage = 'Full access';
    break;
  case 'editor':
    statusMessage = 'Can edit content';
    break;
  case 'viewer':
    statusMessage = 'Read-only';
    break;
  default:
    statusMessage = 'Unknown';
}

// Switch expression: declarative, returns immediately
final statusMessage = switch (userRole) {
  'admin' => 'Full access',
  'editor' => 'Can edit content',
  'viewer' => 'Read-only',
  _ => 'Unknown',
};

In real Flutter apps, this shines when building widgets. My e-commerce platform used this pattern extensively for rendering different checkout states:

dart
final checkoutWidget = switch (checkoutState) {
  CheckoutInitial() => const CheckoutForm(),
  CheckoutProcessing() => const PaymentLoader(),
  CheckoutSuccess(:final orderId) => OrderConfirmation(orderId: orderId),
  CheckoutError(:final error) => ErrorBanner(message: error),
};

The expression returns a widget directly, no variable assignment pollution. This works with any type: strings, ints, bools, widgets, custom classes. Every arm must return the same type, and the compiler enforces this.

Pattern matching on types and fields

Switch expressions get their real power from pattern matching. You can destructure objects, match on types, extract fields, and apply guards all in one expression. This eliminates the need for separate type checks and getters.

Let's say you're managing a ride-hailing app's booking states. Without pattern matching, you'd do this:

dart
// Verbose: manual type checking and field access
String displayStatus(BookingState state) {
  if (state is BookingConfirmed) {
    return 'Driver assigned: ${state.driverName}';
  } else if (state is BookingCancelled) {
    return 'Cancelled: ${state.reason}';
  } else if (state is BookingCompleted) {
    final rating = state.rating;
    if (rating != null && rating >= 4) {
      return 'Great ride! Thanks for 5 stars!';
    }
    return 'Ride completed';
  }
  return 'Unknown state';
}

With switch expressions and pattern matching:

dart
// Clean: extract and match in one line
String displayStatus(BookingState state) => switch (state) {
  BookingConfirmed(:final driverName) => 'Driver assigned: $driverName',
  BookingCancelled(:final reason) => 'Cancelled: $reason',
  BookingCompleted(:final rating?) when rating != null && rating >= 4 
    => 'Great ride! Thanks for 5 stars!',
  BookingCompleted() => 'Ride completed',
  _ => 'Unknown state',
};

Notice the record patterns like :final driverName. This destructures the field in one go. The rating? syntax checks nullability. The when guard applies additional logic. No casting, no null checks scattered everywhere.

On a travel booking platform I built, this became invaluable for complex search results:

dart
final displayCard = switch (searchResult) {
  FlightResult(:final airline, :final price) when price < 5000 
    => CheapFlightCard(airline: airline, price: price),
  FlightResult(:final airline) 
    => StandardFlightCard(airline: airline),
  HotelResult(:final stars, :final reviews) when stars >= 4 && reviews > 100 
    => PremiumHotelCard(stars: stars),
  HotelResult(:final name) 
    => BasicHotelCard(name: name),
  _ => SizedBox.shrink(),
};

Each arm extracts exactly what it needs, applies business logic (the guard), and returns the right widget. This is declarative, testable, and easy to modify when requirements change.

Compiler checks exhaustiveness for sealed types

This is where switch expressions become a safety tool. When you use a sealed class, the compiler knows every possible subtype. If you forget a case, the compiler warns you—not at runtime during a user session, but during development.

Sealed classes are perfect for state machines:

dart
sealed class ApiResponse {}

class Loading extends ApiResponse {}
class Success extends ApiResponse {
  final dynamic data;
  Success(this.data);
}
class Error extends ApiResponse {
  final String message;
  Error(this.message);
}

// The compiler knows these are the only three subtypes
final display = switch (response) {
  Loading() => CircularProgressIndicator(),
  Success(:final data) => DataWidget(data: data),
  Error(:final message) => ErrorWidget(message: message),
  // Forget the Error case? Compiler error.
};

I've seen teams catch real bugs this way. A CRM platform I worked on had dozens of sales pipeline states. When we added a new "Archived" state and used a sealed class with switch expressions, the compiler immediately flagged every place that needed to handle it. No silent failures.

Without exhaustiveness checking, you'd need a catch-all default case that might hide logic errors. With sealed types, the compiler forces you to be explicit:

dart
sealed class PaymentStatus {}
class Pending extends PaymentStatus {}
class Completed extends PaymentStatus {}
class Refunded extends PaymentStatus {}
class Disputed extends PaymentStatus {} // New state added

// This won't compile until you handle Disputed
final label = switch (status) {
  Pending() => 'Awaiting payment',
  Completed() => 'Paid',
  Refunded() => 'Refunded',
  // Error: not all cases covered
};

Cleaner UI state rendering

The practical payoff is in your UI layer. Instead of builder functions with nested conditions, you get declarative state-to-widget mapping. This is especially powerful with BLoC, Riverpod, or Provider.

Consider a trading app displaying market data:

dart
// Without switch expressions: verbose, hard to read
Widget _buildMarketWidget(MarketState state) {
  if (state is MarketLoading) {
    return const MarketSkeleton();
  }
  if (state is MarketLoaded) {
    if (state.stocks.isEmpty) {
      return const EmptyMarket();
    }
    return MarketGrid(stocks: state.stocks, trend: state.trend);
  }
  if (state is MarketError) {
    return ErrorRetry(
      message: state.message,
      onRetry: () => context.read().add(ReloadMarket()),
    );
  }
  return const SizedBox.shrink();
}

With switch expressions:

dart
// Concise, readable, matches domain logic
Widget _buildMarketWidget(MarketState state) => switch (state) {
  MarketLoading() => const MarketSkeleton(),
  MarketLoaded(stocks: []) => const EmptyMarket(),
  MarketLoaded(:final stocks, :final trend) => MarketGrid(stocks: stocks, trend: trend),
  MarketError(:final message) => ErrorRetry(
    message: message,
    onRetry: () => context.read().add(ReloadMarket()),
  ),
};

The entire method is now an expression. It's scannable—you see all states at a glance. Adding a new state means the compiler catches missed cases. This scales beautifully across large apps with many states.

In a ride-hailing system I built, every screen used this pattern:

dart
BlocBuilder(
  builder: (context, state) => switch (state) {
    RideSearching() => const RideSearchView(),
    RideAvailable(:final driver, :final eta) => RideAvailableCard(driver: driver, eta: eta),
    RideEnRoute(:final location) => LiveTrackingMap(location: location),
    RideCompleted(:final fare, :final rating) => RideRatingSheet(fare: fare, initialRating: rating),
    RideError(:final error) => ErrorDialog(message: error),
  },
);

This pattern became our standard. Every developer knew that if they added a state, they'd see compiler errors until they handled it in all switch expressions.

Common mistakes

Forgetting the catch-all pattern: If your type isn't sealed, the compiler won't enforce exhaustiveness. Always include _ => defaultValue as a fallback.

dart
// Compiles fine, but risky if you add a case
final result = switch (value) {
  'a' => 'Alpha',
  'b' => 'Beta',
  // What if value is 'c'? Returns null implicitly
};

// Better: handle explicitly
final result = switch (value) {
  'a' => 'Alpha',
  'b' => 'Beta',
  _ => 'Unknown',
};

Mismatched return types: Every arm must return the same type. The compiler catches this, but it's easy to accidentally return a widget in one arm and a Future in another.

Over-extracting fields: You don't need to extract every field. Only destructure what you actually use:

dart
// Unnecessary
final display = switch (data) {
  Success(:final id, :final name, :final email, :final phone) => ...,
};

// Better: extract what matters
final display = switch (data) {
  Success(:final name) => NameDisplay(name: name),
};

Complex guards hiding logic: If your guard condition is multi-line, consider extracting it to a helper method. Switch expressions are meant to be readable.

Key takeaways

  • Switch expressions return values directly, replacing imperative assignments with declarative expressions.
  • Pattern matching with destructuring eliminates verbose type checks and manual field access.
  • Sealed classes + switch expressions give you compiler-enforced exhaustiveness, catching missing cases before production.
  • Use switch expressions consistently in your UI layer for clean, maintainable state-to-widget mapping.
  • Always include a catch-all pattern unless your type is sealed and you've covered all cases.
  • Extract only the fields you actually need; avoid polluting the expression with unnecessary destructuring.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles