iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔧

Mastering Extensions in Dart

に公開

Introduction

Dart has a convenient feature called extension, which allows you to add functionality to existing types.
This article provides a comprehensive explanation, from the differences between named and anonymous extensions, to practical usage, and examples of extension type utilization.

Target Audience

  • Those who know how to write extension in Dart and Flutter but are unsure about specific use cases.
  • Those who are not clear on the purpose or use cases of extension type.
  • Those who want to design maintainable code.
  • Those who want to organize common processes and utilities in Flutter apps.

Author's Environment at the Time of Writing

[✓] Flutter (Channel stable, 3.27.1, on macOS 15.1 24B2082 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.96.2)

What is extension?

https://dart.dev/language/extension-methods

Dart's extension feature, which translates to "extension", allows you to define additional methods, getters, and setters for a target class or type.
It literally extends it.

There are actually two types of extensions:

  • Named extensions
  • Anonymous extensions

Named Extensions

The syntax is as follows:

extension <extension name>? on <type> { 
  // ...
}

The <extension name>? part defines the name of this extension, indicating what kind of extension it is.
As the ? suggests, this is actually nullable, but more on that later.
Next, you write the target to be extended inside <type>.

Based on the above, a concrete implementation example is as follows:

lib/data/repositories/repository.dart
// Original implementation
class ExampleRepository {
  const ExampleRepository({required this.list});

  static const _id = '123';

  final List<String> list;

  Future<void> fetch() async {
    print(_id);
    print(list);
  }
}
lib/data/repositories/extension_name.dart
// ignore_for_file: avoid_print

import 'package:simple_base/data/repositories/repository.dart';

// Named extension
// Can be defined in a separate file
extension ExtensionName on ExampleRepository {
  void custom() {
    // ...
    _privateMethodInExtensionName();
  }

  void _privateMethodInExtensionName() {
    // ...

    // 🙅 Cannot call private members of ExampleRepository here
    // print(ExampleRepository._id);

    // 👍 Can access public members of ExampleRepository
    print(list);
  }
}

We have defined an extension named ExtensionName and a custom method within it.
In this particular example, there's no strong need to define it in an extension 😇.
If ExampleRepository's implementation becomes too long, or if you want to separate it into files at a certain granularity, this approach is effective.
A point to note is that while public members can be accessed, private members cannot be called.

Anonymous Extensions

Conversely, the syntax for an anonymous extension is as follows:

extension on <Type>{
  // ...
}

This is simply the named extension without a name, but this extension has the characteristic that it can only be defined within the same file as the target class or type.
Its main use is to group private methods of the target class or type.

lib/data/repositories/repository.dart
// ignore_for_file: avoid_print

class ExampleRepository {
  const ExampleRepository({required this.list});

  static const _id = '123';

  final List<String> list;

  Future<void> fetch() async {
    print(_id);
    print(list);
  }

  Future<void> save() async {
    _privateMethod();
  }
}

// Anonymous extension
// Must be defined in the same file
extension on ExampleRepository {
  // Although anonymous extensions become private without an underscore,
  // it's clearer to include one.
  void _privateMethod() {
    // 👍 Can access private members if defined in the same file
    print(ExampleRepository._id);
    // 👍 Can access public members of ExampleRepository
    print(list);
  }
}

A _privateMethod is defined in an anonymous extension.
Normally, private methods can be defined within ExampleRepository using an underscore.
However, if the implementation becomes extensive, it can become complicated, so it's useful to separate it from the main implementation.
A key feature is that since it's defined in the same file, it can access private members.

Comparison of Named and Anonymous Extensions from the Caller's Perspective

import 'package:simple_base/data/repositories/extension_name.dart';
import 'package:simple_base/data/repositories/repository.dart';

class OtherRepository {
  final _exampleRepository = const ExampleRepository(list: ['1', '2']);

  Future<void> fun() async {
    // Can be called as usual
    await _exampleRepository.fetch();
    await _exampleRepository.save();

    // Named extensions can be called
    // However, import is required
    // import 'package:simple_base/data/repositories/extension_name.dart';
    _exampleRepository.custom();

    // 🙅 Anonymous extension methods are private, so they cannot be called
    //
    // The method 'fetchDataWithPrivateExtension' isn't defined
    // for the type 'ExampleRepository'.
    // Try correcting the name to the name of an existing method,
    // or defining a method named 'fetchDataWithPrivateExtension'.
    //
    // await _exampleRepository._privateMethod();

    // 🙅 Private methods of named extensions also cannot be called
    // _exampleRepository._privateMethodInExtensionName();
  }
}

As mentioned in the comments, methods defined in anonymous extensions cannot be called from other files.
Conversely, methods defined in named extensions can be called from the same instance.

Anonymous Extensions: UI Edition

If tap handling is written at length within the build method, it can make the overall UI elements difficult to grasp and create noise, so I separate them using anonymous extensions.
By doing this, the build method remains simple with only UI elements, and methods can be encapsulated within anonymous extensions, improving readability.

import 'package:flutter/material.dart';

class ExampleScreen extends StatelessWidget {
  const ExampleScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: _onButton1Pressed, // 💡
          child: const Text('Button 1'),
        ),
        ElevatedButton(
          onPressed: _onButton2Pressed, // 💡
          child: const Text('Button 2'),
        ),
        ElevatedButton(
          onPressed: _onButton3Pressed, // 💡
          child: const Text('Button 3'),
        ),
      ],
    );
  }
}

extension on ExampleScreen {
  void _onButton1Pressed() {
    // ...
  }
  void _onButton2Pressed() {
    // ...
  }
  void _onButton3Pressed() {
    // ...
  }
}

Named Extensions: Standard Library Types Edition

Extensions are not limited to self-created classes; they can also be applied to standard library types provided by Dart.

DateTime

extension DateTimeUtil on DateTime {
  /// Formats the date and time as "yyyy/MM/dd HH:mm"
  String toTimeString() {
    final y = year.toString().padLeft(4, '0');
    final m = month.toString().padLeft(2, '0');
    final d = day.toString().padLeft(2, '0');
    final h = hour.toString().padLeft(2, '0');
    final min = minute.toString().padLeft(2, '0');

    return '$y/$m/$d $h:$min';
  }

  /// Formats only the date as "yyyy/MM/dd"
  String toDateString() {
    final y = year.toString().padLeft(4, '0');
    final m = month.toString().padLeft(2, '0');
    final d = day.toString().padLeft(2, '0');

    return '$y/$m/$d';
  }

  /// Returns the date in Japanese format as yyyy年mm月dd日
  String toJapaneseDateString() {
    final y = year.toString().padLeft(4, '0');
    final m = month.toString().padLeft(2, '0');
    final d = day.toString().padLeft(2, '0');

    return '$y$m$d日';
  }
}
void main() {
  final now = DateTime(2023, 10, 1, 12, 34);
  print(now.toTimeString()); // "2023/10/01 12:34"
  print(now.toDateString()); // "2023/10/01"
  print(now.toJapaneseDateString()); // "2023年10月01日"
}

String

extension StringUtil on String {
  /// Converts hiragana characters in the string to katakana
  String toKatakana() => replaceAllMapped(
        RegExp('[ぁ-ゔ]'),
        (Match match) =>
            String.fromCharCode(match.group(0)!.codeUnitAt(0) + 0x60),
      );

  /// Checks if the string is a music ID starting with `MUS`
  bool get isMusicId => startsWith('MUS');

  /// Checks if the string is a TODO ID starting with `TODO`
  bool get isTodoId => startsWith('TODO');
}
void main() {
  const str = 'あいうえお';
  print(str.toKatakana()); // アイウエオ

  const musicId = 'MUS12345';
  print(musicId.isMusicId); // true
  print(musicId.isTodoId); // false

  const todoId = 'TODO12345';
  print(todoId.isMusicId); // false
  print(todoId.isTodoId); // true
}

Extension Types

Finally, I'd like to introduce a slightly special use case. An extension type is a mechanism used to assign meaning to existing types like String or int, effectively saying, 'this value has a special meaning.'

https://dart.dev/language/extension-types

Specifically, it looks like this:

// ignore_for_file: avoid_print
/// Wraps String with a UserId type
extension type UserId(String value) {
  String get id => value;
}

class User {
  const User({
    required this.id,
    required this.name,
    required this.email,
  });
  final UserId id;
  final String name;
  final String email;
}

class UserRepository {
  const UserRepository();

  User getUser() {
    return User(
      id: UserId('123'),
      name: 'John Doe',
      email: 'example@mail.com',
    );

    // 🙅
    // The User class requires a UserId type for its id, so a plain String cannot be passed.
    // This prevents bugs caused by accidentally passing the wrong type.
    //
    // The argument type 'String' can't be assigned
    // to the parameter type 'UserId'.
    //
    // return User(
    // id: '123',
    //   name: 'John Doe',
    // );
  }

  UserId getUserId() {
    return UserId('123');
  }

  void createUser(String name) {
    final userId = getUserId();
    final user = User(
      id: userId,
      name: name,
      email: 'example@mail.com',
    );
    // Save process
    print(user);
  }
}

Here, we are defining a type for the id in the User class, rather than a UserId class itself.

UserId looks like a unique type, but at runtime it is treated as a String (Zero-cost abstraction), so it does not affect performance. However, in static type checking, it is strictly treated as a UserId, striking a balance between safety and efficiency.

It's a bit complex, but essentially, this is a way to label a String, saying, "This is a String, but its meaning is UserId."

While the usage can be tricky, it's effective to wrap values when you have multiple String parameters received from an API, and you absolutely must use a specific value for a certain parameter.

Conclusion

Dart's extension is a reliable feature that helps us make the code we deal with every day more manageable. It supports the fundamentals of good design—organizing logic, separating concerns, and enhancing code reusability—in a natural way.

It is especially effective for organizing build methods, UI event handling, and utility methods that tend to become lengthy. By flexibly using anonymous extensions to keep UI code clean and named extensions to modularize processes, you can evolve your code to be more readable and maintainable.

"Can I write this logic a bit more cleanly?"
"Similar processes are scattered all over the place..."
When you think this, extension is the first thing to consider.

I hope this article helps improve the readability of your code. 🙏

Discussion