【Dart】extensionを使いこなす
はじめに
Dartには、既存の型に機能を追加する extension
という便利な機能があります。
この記事では、名前付き・無名エクステンションの違いから、実践的な使い方、そしてextension type
の活用例まで幅広く解説します。
記事の対象者
- DartやFlutterで
extension
の書き方は知っているが、具体的な使い分けに迷っている人 -
extension type
の用途や使いどころがピンと来ていない人 - メンテナブルなコード設計をしたい人
- Flutterアプリで共通処理やユーティリティを整理したいと考えている人
記事を執筆時点での筆者の環境
[✓] 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)
extensionとは?
dartの extension
は拡張と訳され、対象のクラスや型に対して追加のメソッドやゲッター、セッターを定義できるようにするものです。
文字通り拡張するわけですね。
その中でも実は種類として2つあります。
- 名前付きエクステンション
- 無名エクステンション
名前付きエクステンション
構文は以下のとおりです。
extension <extension name>? on <type> {
// ...
}
<extension name>?
の部分にこの拡張はどんな拡張なのかの名前を定義します。
?
がついていることから分かるとおり実はこれはnullableなのですが、詳しくは後述します。
次に拡張する対象を <type>
の中に記述していきます。
上記を踏まえて具体的に実装した例は以下のような形になります。
// 大元の実装
class ExampleRepository {
const ExampleRepository({required this.list});
static const _id = '123';
final List<String> list;
Future<void> fetch() async {
print(_id);
print(list);
}
}
// ignore_for_file: avoid_print
import 'package:simple_base/data/repositories/repository.dart';
// 名前付きエクステンション
// 別ファイルに定義できる
extension ExtensionName on ExampleRepository {
void custom() {
// ...
_privateMethodInExtensionName();
}
void _privateMethodInExtensionName() {
// ...
// 🙅 ここではExampleRepositoryのprivateメンバは呼び出せない
// print(ExampleRepository._id);
// 👍 ExampleRepositoryのpublicメンバにはアクセスできる
print(list);
}
}
ExtensionName
という拡張を定義し、その中に custom
メソッドを定義しています。
今回の例で言うとあえて拡張の方に定義する必要はないのですが😇
この例で言うと強いて言うならExampleRepository
の実装が長くなってきた場合や、ある程度の粒度で別ファイルに分けたい場合に有効です。
注意点としてはpublicメンバにはアクセスできますが、privateメンバは呼び出せない点ですね。
無名エクステンション
反対に無名エクステンションの構文は以下です。
extension on <Type> {
// ...
}
先ほどの名前つきエクステンションの名前をなくしただけですが、この拡張は 対象のクラスや型と同じファイル内でしか定義できない という特徴があります。
主な用途は対象クラスや型のprivateメソッドをまとめておくことに使用します。
// 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();
}
}
// 無名エクステンション
// 同一ファイル内に定義する必要あり
extension on ExampleRepository {
// アンダースコアつけなくても無名エクステンションはprivateになるけど
// つけたほうがわかりやすい
void _privateMethod() {
// 👍 同一ファイル内であればprivateメンバーにアクセスできる
print(ExampleRepository._id);
// 👍 ExampleRepositoryのpublicメンバにはアクセスできる
print(list);
}
}
無名エクステンションに _privateMethod
を定義しています。
通常、 ExampleRepository
内にアンダースコアをつけてprivateメソッドは定義できます。
しかし、実装が多くなれば煩雑になってしまうので本実装と分けたい場合には有用です。
特徴としては同一ファイル内に定義しているのでprivateメンバーにもアクセスできるところです。
名前付きエクステンションと無名エクステンションの呼び出し側での比較
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 {
// 通常通り呼び出せる
await _exampleRepository.fetch();
await _exampleRepository.save();
// 名前付きエクステンションは呼び出せる
// ただしインポートは必要
// import 'package:simple_base/data/repositories/extension_name.dart';
_exampleRepository.custom();
// 🙅 無名エクステンションはprivateメソッドになるので呼び出せない
//
// 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();
// 🙅 名前付きのextensionのプライベートメソッドも呼び出せない
// _exampleRepository._privateMethodInExtensionName();
}
}
コメントにある通り、無名エクステンションで定義したメソッドは別ファイルからは呼び出せません。
反対に名前付きエクステンションで定義したメソッドは同じインスタンスから呼び出すことができます。
無名エクステンション: UI編
ビルド内でタップ処理を長々と書いてしまうと全体のUI要素の見通しが悪くなりノイズになってしまうので、無名エクステンションで分けるようにしています。
こうすることで、ビルド内はUI要素のみにシンプルに保ち、メソッドは無名エクステンション内に隠蔽できるので可読性が向上します。
import 'package:flutter/material.dart';
class ExampleScreen extends StatelessWidget {
const ExampleScreen({super.key});
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() {
// ...
}
}
名前付きエクステンション: 標準ライブラリの型編
拡張できるのは自作のクラスに限らず、Dartが提供する標準ライブラリの型にも適用できます。
DateTime
extension DateTimeUtil on DateTime {
/// 日付と時間を "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';
}
/// 日付のみを "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';
}
/// 日本語形式で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 {
/// 文字列の中に含まれるひらがなをカタカナに変換する
String toKatakana() => replaceAllMapped(
RegExp('[ぁ-ゔ]'),
(Match match) =>
String.fromCharCode(match.group(0)!.codeUnitAt(0) + 0x60),
);
/// 文字列が `MUS` から始まる音楽IDかどうか
bool get isMusicId => startsWith('MUS');
/// 文字列が `TODO` から始まるTODO IDかどうか
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 type)とは、Stringやintなどの既存の型に、これは特別な意味を持つ値だよと型として意味付けしたいときに使う仕組みです。
具体的には以下のような形です。
// ignore_for_file: avoid_print
/// StringにUserIdという型でラップする
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',
);
// 🙅
// User クラスは id に UserId 型を要求しているため、ただの String は渡せません。
// これによって、間違った型をうっかり渡すバグを防げます。
//
// 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',
);
// 保存処理
print(user);
}
}
ここでは User
クラスの id
に UserId
というクラスではなく、型を定義しています。
UserId
は見た目は独自型ですが、実行時には String として扱われる(Zero-cost abstraction) ため、パフォーマンスに影響を与えません。
ただし、静的型チェックではしっかりとUserIdとして扱われるため、安全性と効率性のバランスがとれています。
ちょっと複雑なのですが、つまりこれは、「Stringだけど、意味はUserIdだよ」というラベルを貼るような使い方です。
使い方は難しいですが、例えばAPIから受け取った値のうち複数のStringのパラメータがあった場合に、この値は絶対にこれを使わないといけない、というものがあった場合にラップするのが良いと思います。
終わりに
Dartの extension
は、私たちが日々向き合うコードを、より見通しの良いものにしてくれる頼もしい存在です。
ロジックを整理し、役割を分離し、コードの再利用性を高める──そんな設計の基本を、extension
は自然な形でサポートしてくれます。
特に、ビルドメソッドやUIのイベント処理、長くなりがちなユーティリティメソッドの整理には絶大な効果を発揮します。
無名エクステンションでUIコードをすっきりまとめたり、名前付きエクステンションで処理をモジュール化したりと、状況に応じて柔軟に使い分けることで、より読みやすく保守しやすいコードへと進化させることができます。
「このロジック、もうちょっと綺麗に書けないかな?」
「似たような処理があちこちに散らばってるな…」
そう思ったとき、まずは extension
の出番です。
この記事をきっかけにみなさんのコードの可読性向上に貢献できれば幸いです🙏
Discussion