Improved coding style with Dart 3

In this short article, I’m gonna share with you a few concrete use cases where pattern matching and records (introduced with Dart version 3) allowed me to refactor some of my code in a much shorter way!

 

Null guards

Before pattern matching, I was using this kind of pattern almost everywhere to check if a field is null or not before processing it: I was affecting the field value to a final local variable and testing its nullity in an if condition. This would allow the compiler to ensure that the local variable isn't null in this scope.

Dart 2

class Example {
    const Example(this.optionalValue);
    final int? optionalValue;

    String process() {
        final optionalValue = this.optionalValue;
        if(optionalValue != null) {
           return 'Processed: ${optionalValue * 2}';
        }

        return 'Not processed';
    }
}

These two lines can now be merged into one pattern, using case in an if condition!

Dart 3

class Example {
    const Example(this.optionalValue);
    final int? optionalValue;

    String process() {
        if(optionalValue case var optionalValue?) {
           return 'Processed: ${optionalValue * 2}';
        }

        return 'Not processed';
    }
}
 

Union types

It is pretty common to define a sealed set of specific types associated with a parent interface. Other languages offer features for that, but it wasn’t the case for Dart. Until now!

Typically before I would define a set of inherited classes, and a map method to allow to switch a current instance and access its inherited properties.

Dart 2

abstract class AsyncState<T> {
    const AsyncState();

    K map<K>({
        K Function() loading,
        K Function(T result) success,
        K Function(dynamic error) error,
    }) {
        final value = this;
        if(value is Loading<T>) {
            return loading();
        }
        if(value is Success<T>) {
            return success(value.result);
        }
        if(value is Failure<T>) {
            return failure(value.error);
        }
        throw Exception();
    }
}

class Loading<T> extends AsyncState<T> {
    const Loading();
}

class Success<T> extends AsyncState<T> {
    const Success(this.result);
    final T result;
}

class Failure<T> extends AsyncState<T> {
    const Failure(this.error);
    final dynamic error;
}
final AsyncState<int> state = ...;

final label = state.map(
    loading: () => 'Loading...',
    success: (result) => 'Success: $result!',
    failure: (error) => 'Failure: $error...', 
)

No need to define a map method anymore with sealed classes and switch expressions!

Since the class can’t be extended outside of this file, the compiler is able to tell if all types have been covered by a switch.

Dart 3

sealed class AsyncState<T> {
    const AsyncState();
}

class Loading<T> extends AsyncState<T> {
    const Loading();
}

class Success<T> extends AsyncState<T> {
    const Success(this.result);
    final T result;
}

class Failure<T> extends AsyncState<T> {
    const Failure(this.error);
    final dynamic error;
}
final AsyncState<int> state = ...;

final label = switch(state) {
    Loading<int> _ => 'Loading...',
    Success<int> state => 'Success: ${state.result}!',
    Failure<int> state => 'Failure: ${state.error}...', 
};

You can even deconstruct an instance to read its properties at the same time if you wish :

final label = switch(state) {
    Loading<int> _ => 'Loading...',
    Success<int>(result: var result) => 'Success: $result!',
    Failure<int>(error: var error) => 'Failure: $error...', 
};
 

Data classes

Sometimes we just want simple classes that just hold a set of properties, and we want these classes to be comparable by value.

Dart 2

@immutable
class User {
  const User({
    required this.firstname,
    required this.lastname,
    required this.email,
  });
  final String firstname;
  final String lastname;
  final String email;

  String mailTo(String subject) {
    return 'mailto:$email?subject=$subject';
  }

  @override
  bool operator ==(covariant User other) {
    if (identical(this, other)) return true;
  
    return 
      other.firstname == firstname &&
      other.lastname == lastname &&
      other.email == email;
  }

  @override
  int get hashCode => Object.hashAll([firstname, lastname, email]);
}

We can now simply use a record instead with extensions. I often use a typedef declaration to make it easier to reference my record type.

It is important to note that a record type is still different than a “real” class since all records with the same property set will be considered as the same type in the end. The typedef is just a “shortcut” for the record type.

Dart 3

typedef User = ({
   String firstname,
   String lastname,
   String email,
});

extension UserMethods on User {
   String mailTo(String subject) {
        return 'mailto:$email?subject=$subject';
   }   
}
 

Equality and hash code

Implementing equality comparison and its associated hashCode has always been boring.

Packages like equatable helped a lot, but it was still one more dependency for a simple task.

Dart 2

class Location {
  const Location(
    this.country,
    this.id,
    this.name,
  );
  final String country;
  final String id;
  final String name;

  @override
  bool operator ==(covariant Location other) {
    if (identical(this, other)) return true;
    return other.country == country && other.id == id;
  }

  @override
  int get hashCode => Object.hashAll([country, id]);
}

Thanks to record types, this can easily by implemented!

Dart 3


class Location {
  const Location(
    this.country,
    this.id,
    this.name,
  );
  final String country;
  final String id;
  final String name;

  (String, String) _equality() => (id, country);

  @override
  bool operator ==(covariant Location other) {
    if (identical(this, other)) return true;
    return other._equality() == _equality();
  }

  @override
  int get hashCode => _equality().hashCode;
}
 

Parsing

Even if we can generate complex and maintainable JSON parsers with code generators, sometimes we just want to read a simple map and extract some data out of it.

Dart 2

bool containsTextNode(dynamic json) {
    if(json is Map) {
       final children = json['children'];
       if(children is List) {
           for(final child in children) {
               if(child is Map) {
                   final type = child['type'];
                   if(type == 'text') {
                       return true;
                   }
               }
           }
       }
    }
    return false;
}

With map and list pattern matching everything becomes a lot more readable!

Dart 3

bool containsTextNode(dynamic json) {
  return switch (json) {
    {'children': List children} => children.any((child) => switch (child) {
          {'type': 'type'} => true,
          _ => false,
        },),
    _ => false,
  };
}

Another example of a JWT token parsing using an array pattern :

Dart 2

final splitToken = jwtToken.split(".");
if (splitToken.length == 3) {
    final headerPart = splitToken[0];
    final payloadPart = splitToken[1];
    final signaturePart = splitToken[2];
    if(headerPart is String && payloadPart is String && signaturePart is String) {
      // Process part
    }
}

throw Exception('Invalid token')

Dart 3

final splitToken = jwtToken.split(".");
if (splitToken case [
          String headerPart,
          String payloadPart,
          String signaturePart,
        ]) {
        // Process parts
}

throw Exception('Invalid token')
 

Conclusion

These are just a few simple examples, but the possibilities are endless!

I highly recommend you check the official Dart website (patterns, records, branches, …) to see all the new functionalities and find new patterns to improve your future code!

Previous
Previous

Custom layout with Flutter

Next
Next

Continuous preview of your Flutter app as a webapp