80
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutterフォームバリデーションの呪縛を解き放て!ZodArtで宣言的に書いてみた

Last updated at Posted at 2025-12-03

この記事のコードは全部GitHubにあります 🎯

Flutterでフォームバリデーションを書いたことのある方は納得していただけると思いますが、バリデーション & 値の取得 —— ボイラープレートコードが多くてコードの再利用性が低いのが現状の課題です

その苦労を少し和らげてくれるパッケージがいくつかありますが、それでもまだ冗長で扱いづらいです。そしてバリデーション後には結果として型安全でないMapを返すことになると、それもまた悲しい話ですよね。

ReactではZodReact Hook Formの組み合わせを使っていてフォームバリデーションがどれだけ楽だったかをいまだに思い出してしまいます。再利用可能で拡張性が高くて、テストのしやすいスキーマとフォームバリデーションをきれいに分離することができています。

こんな仕組みがもしFlutterにもあったら——宣言的かつフルエントなバリデーション体験と、Dartの洗練された型システム❤️の恩恵を受けられるような仕組み——最高じゃないですか? 🚀

そんな思いから私は ZodArt🎯 —— 静的型推論、パースファーストのバリデーションパッケージ を開発しました。

この記事では、Flutterのフォームバリデーションを題材に、ZodArtを使って紹介したいと思います。

ZodArtの紹介

使い方やすぐ試せるサンプルは、ZodArtのページを参照

ZodArtはZodのフルエントなDSL(Domain-specific Language)をベースにしてDartの強力な型システムと組み合わせることを目指しています。

ZodArtでのバリデーションは簡単です:

🎯 ZodArtの紹介
import 'package:zodart/zodart.dart';

void main() {
  /// 文字列の長さは 1 から 20 の間でなければならない
  final minMaxSchema = ZString().trim().min(1).max(20);

  final res = minMaxSchema.parse('  ZodArt ');
  
  // 解析結果を確認するには `.isSuccess` を使用
  if (res.isSuccess) {
    print('成功: ${res.value}');
  }

  /// [minMaxSchema] を拡張して、null 値を許可する
  final nullableSchema = minMaxSchema.nullable();

  // null 値を検証
  print(nullableSchema.parse(null).value);
}

少し複雑なオブジェクトになっても、スキーマの定義は割と簡単です:

  1. クラスを作る
  2. スキーマを定義する
  3. コードの自動生成を使う

ZodArtでは任意ですが、コード生成機能を使用することを推奨します。なぜならDartチームはマクロ機能の開発を一時停止でコード生成はDartにおけるメタプログラミングの最も実用的な手法ですから。

🎯 ZodArt —— オブジェクトパース
import 'package:zodart/zodart.dart';

part '<FILE_NAME>.zodart.dart';
part '<FILE_NAME>.zodart.type.dart';

// アイテムスキーマ(自動的に`Item`クラスが生成されます)
@ZodArt.generateNewClass(outputClassName: 'Item')
abstract class ItemSchema {
  /// スキーマ定義
  static final schema = (
    id: ZInt().min(1).max(9999),
    name: ZString().trim().min(1).max(20),
    archived: ZBool().optional(), // 任意フラグ
  );

  // 生成された補助メソッド(props リストなど)にアクセスできます。
  static const z = _ItemSchemaUtils();
  static final ZObject<Item> zObject = z.zObject;
}

void main() {
  // 未知のMapをパース
  final res = ItemSchema.zObject.parse({
    'id': 7,
    'name': 'クッキー',
  });

  // パースの結果を出力
  res.match(
    (issues) => print('❌ バリデーションが失敗しました: ${issues.localizedSummary}'),
    (item) => print('🟢 バリデーションが成功しました: $item'),
  );

  // `item.id` に関する問題だけを取得するには `getSummaryFor` を使用します。
  final idIssueSummary = res.getSummaryFor(ItemSchemaProps.id.name);
  print('Item.idのエラー: $idIssueSummary');
}

スピードアップのためVS Codeの拡張機能であるZodArt snippetsはお勧めです。
zodart_snippets_new_class.gif

でも今freezedを使っていますから、変えたくない。 🎯 変えなくてもいいです!

ZodArt & Flutter

この記事で書いてみたいのは:

  1. 初期版ZodArtフォームバリデーション
    • Flutter Hooksを使ったシンプルなフォームバリデーション
  2. ZodArtのみのミニパッケージ風
    • 外部パッケージに依存せず、FlutterとZodArtだけで再利用可能な“ミニパッケージ風”実装例

Flutterアプリのセットアップ

  1. 新規Flutterアプリを作成 https://docs.flutter.dev/reference/create-new-app
  2. ZodArt(>=1.2.0)とRiverpod、Flutter Hooksを追加
    flutter pub add zodart
    flutter pub add hooks_riverpod
    flutter pub add flutter_riverpod
    flutter pub add flutter_hooks
    flutter pub add riverpod_annotation
    flutter pub add dev:riverpod_generator
    flutter pub add dev:build_runner
    flutter pub add dev:custom_lint
    flutter pub add dev:riverpod_lint
    

初期版ZodArtフォームバリデーション

Riverpodの公式ページに沿ってRiverpodephemeral stateの管理を避けるべきですので、Flutter hooksと合わせて簡単なフォームバリデーションを書いてみたいと思います。

例として、簡単なユーザー登録フォームを作成します。

初期バージョンはこのようになります:

zodart_form_simple.gif

ZodArtのスキーマを作成

最初はZodArtを使ってユーザのスキーマを作成します。

ファイル名はご自身のファイル名に合わせてください:

part <FILE_NAME>.zodart.dart;
part <FILE_NAME>.zodart.type.dart;
🎯 ZodArtのスキーマを定義
import 'package:zodart/zodart.dart';

part 'user_schema.zodart.dart';
part 'user_schema.zodart.type.dart';

@ZodArt.generateNewClass(outputClassName: 'User')
abstract class UserSchema {
  static final schema = (
    firstName: ZString().trim().min(1).max(5),
    lastName: ZString().trim().min(1).max(5),
    email: ZString().trim().regex(
      r'^((?!\.)[\w\-_.]*[^.])(@\w+)(\.\w+(\.\w+)?[^.\W])$',
    ),
  );

  static const z = _UserSchemaUtils();
  static final ZObject<User> zObject = z.zObject;
}

Build RunnerUserのクラス等を自動生成します。

dart run build_runner build

MaterialAppのウィジェット作成

MaterialAppのウィジェットを作成して日本語をデフォルト言語として設定します:

🎯 MaterialAppのウィジェット
import 'package:flutter/material.dart';
import 'package:zodart/zodart.dart';

import 'my_form.dart';

void main() {
  // デフォルト言語を日本語に設定します
  ZLocalizationContext.current = ZIssueLocalizationService(Language.ja);

  runApp(
    const MyApp(),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ZodArt Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: const Text('ZodArt Demo'),
        ),
        body: Center(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 40),
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxWidth: 600),
              child: MyForm(),
            ),
          ),
        ),
      ),
    );
  }
}

ZodArtのフォーム作成

ZodArtで自動生成されたUserSchemaPropsUserSchema.zObjectを活用して簡単なバリデーションを書きます。

手順:

  1. フィールドのonSavedを使ってrawValueにユーザインプットを保存する
  2. rawValueの値をZodArtでパースする
  3. フィールドごとgetSummaryFor(フィールド名)を使ってエラーメッセージを取得する
  4. バリデーション結果をSnackBarで表示する

擬似コードはこうなります:

class MyForm extends HookWidget {
  MyForm({super.key});
  
  final _formKey = GlobalKey<FormState>();
  
  @override
  Widget build(BuildContext context) {
    // 保存用マップ: 入力値を一時的に保持。useRefを使ってフォーム全体のリビルドを避ける
    final rawValue = useRef<Map<String, String?>>({});

    // バリデーション結果: ZodArtで解析された結果を保持
    final parsedValue = useState<ZRes<User>?>(null);

    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: InputDecoration(
              errorText: parsedValue.value?.getSummaryFor(<FIELD_PATH>),
            ),
            onSaved: (val) {
              rawValue.value[<FIELD_PATH>] = val;
            },
          ),
          //...
          ElevatedButton(
            onPressed: () {
              // フィールドの値を[rawValue]に保存
              _formKey.currentState!.save();

              // ユーザをバリデーションして[parsedValue]に保存
              final user = UserSchema.zObject.parse(rawValue.value);
              parsedValue.value = user;

              //  SnackBarでバリデーション結果(`user`)を表示する
            },
            child: const Text('登録'),
          ),
        ],
        // ...
🎯 ZodArtフォームの完全コード
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:zodart/zodart.dart';

import 'models/user_schema.dart';

class MyForm extends HookWidget {
  MyForm({super.key});

  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    print('フォームがbuildされました。');

    // 保存用マップ: 入力値を一時的に保持。useRefを使ってフォーム全体のリビルドを避ける
    final rawValue = useRef<Map<String, String?>>({});

    // バリデーション結果: ZodArtで解析された結果を保持
    final parsedValue = useState<ZRes<User>?>(null);

    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: InputDecoration(
              labelText: '名前',
              errorText: parsedValue.value?.getSummaryFor(
                UserSchemaProps.firstName.name,
              ),
            ),
            onSaved: (val) {
              rawValue.value[UserSchemaProps.firstName.name] = val;
            },
          ),
          TextFormField(
            decoration: InputDecoration(
              labelText: '名字',
              errorText: parsedValue.value?.getSummaryFor(
                UserSchemaProps.lastName.name,
              ),
            ),
            onSaved: (val) {
              rawValue.value[UserSchemaProps.lastName.name] = val;
            },
          ),
          TextFormField(
            decoration: InputDecoration(
              labelText: 'メール',
              errorText: parsedValue.value?.getSummaryFor(
                UserSchemaProps.email.name,
              ),
            ),
            onSaved: (val) {
              rawValue.value[UserSchemaProps.email.name] = val;
            },
          ),
          const SizedBox(
            height: 10,
          ),
          ElevatedButton(
            onPressed: () {
              // フィールドの値を[rawValue]に保存
              _formKey.currentState!.save();

              // ユーザをバリデーションして[parsedValue]に保存
              final user = UserSchema.zObject.parse(rawValue.value);
              parsedValue.value = user;

              // 結果によってのSnackBar
              final snackBar = user.match(
                (_) => SnackBar(
                  content: const Text('バリデーション失敗!'),
                  backgroundColor: Theme.of(context).colorScheme.error,
                ),
                (user) => SnackBar(
                  content: Text('バリデーション成功: $user'), // [User]オブジェクト
                  backgroundColor: Theme.of(context).colorScheme.secondary,
                ),
              );

              // 結果をスナックバーで表示
              ScaffoldMessenger.of(context)
                ..clearSnackBars()
                ..showSnackBar(snackBar);
            },
            child: const Text('登録'),
          ),
        ],
      ),
    );
  }
}

これで簡単なフォームバリデーションは完成です。

もちろん、この初期バージョンには問題があります:

  • DRYの原則を守っていない
  • 「登録」ボタン押下後フォーム全体がrebuildされる
  • 「登録」押下後のみのバリデーションはUX的に良くない
  • 多言語化を考慮していない
  • 入力された値の維持

ZodArtのみのミニパッケージ風

ここからは「本格的なフォームを毎回ゼロから書くのは辛い!」という方のために、
ZodArtだけで作る再利用可能な “ミニパッケージ風” を書いてみて紹介します。

バニラFlutterだけでZodArtをフォームバリデーションに組み込んでみましょう。

最終バージョンはこのようになります:
zodart_form_example.gif

手順:

  1. 各フィールドの値とメタデータを保持するためのストラクチャを作成する
  2. そのストラクチャを操作するコントローラーを作成する
  3. フィールドごとのエラーメッセージを監視するため、FlutterのValueNotifierを利用する
  4. コントローラーを扱える専用ウィジェットを作成する

完成したバージョンはZodArt のポテンシャルを最大限に引き出し、フォームバリデーションを直感的で使いやすいものに仕上げつつ、スキーマを適切に分離した構成を維持します。

🎯 フォームの疑似コード
class MyForm extends ConsumerStatefulWidget {
  const MyForm({super.key});

  @override
  ConsumerState<MyForm> createState() => _MyFormState();
}

class _MyFormState extends ConsumerState<MyForm> {
  final formKey = GlobalKey<FormState>();
  final _zFormController = ZFormController(
    zObject: UserSchema.zObject,
    defaultValues: {
      UserSchema.z.props.firstName: 'Zod',
      // ... 他のフィールド同様 ...
    },
  );

  @override
  Widget build(BuildContext context) {
    // 言語変更時にフォームを再バリデーションする
    ref.listen(
      zodArtLanguageProvider,
      (_, _) => _zFormController.validateForm(),
    );

    return Column(
      children: [
        ZFormField(
          labelText: '名前',
          zFormController: _zFormController,
          field: UserSchemaProps.firstName,
        ),
        // ... 他のフィールド同様 ...
        ElevatedButton(
          onPressed: () {
            _zFormController.submitForm();
            
            // 結果によってのSnackBar
            final snackBar = _zFormController.parsedValue.match(
              (_) => SnackBar(
                content: const Text('バリデーション失敗!'),
                backgroundColor: Theme.of(context).colorScheme.error,
              ),
              (user) => SnackBar(
                content: Text('バリデーション成功: $user'), // [User]オブジェクト
                backgroundColor: Theme.of(context).colorScheme.secondary,
              ),
            );

            // 結果をスナックバーで表示
            ScaffoldMessenger.of(context)
              ..clearSnackBars()
              ..showSnackBar(snackBar);
          },
          child: const Text('登録'),
        ),
      ],
    );
  }
}

これだけです!では、作ってみましょう!

フォームステート作成

フォームデータを保存するためのクラスを作ります:

  • 各フィールドのメタデータ(初期値、dirtyフラグ、touchedフラグ)のクラス
  • 全フィールドの値とメタデータのクラス
🎯 フォームステートの疑似コード
/// フィールドのメタデータ
class ZFormFieldMetadata<T> {
  const ZFormFieldMetadata._({
    required this.initialValue,
    required this.dirty,
    required this.touched,
  });
  // ...
}

/// 全フィールドのデータ
///
/// フィールドごとのメタデータと全フィールドの入力された値
class ZFormFields {
  const ZFormFields._({
    required Map<String, String?> rawValues,
    required Map<String, ZFormFieldMetadata<String?>> formFieldsMetadata,
  }) : _rawValues = rawValues,
       _formFieldsMetadata = formFieldsMetadata;
  // ...
}

フォームコントローラー作成

フォームステートの保存&管理をするコントローラークラスを作ります。

コントローラーの主な責務:

  • フォームステートの保存&管理
  • バリデーション結果(エラーメッセージ)をUIに反映させるため、リスナーを管理する
  • 必要なバリデーション実行の数を減らすため、デバウンスの管理
🎯 フォームコントローラーの疑似コード
/// バリデーション実行のデバウンス
///
/// 必要なバリデーション実行の数を減らすため
class _Debouncer {
  _Debouncer({this.debounceDelay = defaultDelay});
  // ...
}

/// エラーメッセージ更新をUIに反映させるため
class _IssueMessagesNotifier {
  _IssueMessagesNotifier({required this.getIssueMessage});

  final Map<String, ValueNotifier<String?>> _issueMessageNotifiers = {};
  final String? Function(String fieldPath) getIssueMessage;

  ValueNotifier<String?> watchIssueMessageFor(String fieldPath) =>
      _issueMessageNotifiers.putIfAbsent(
        fieldPath,
        () => ValueNotifier(getIssueMessage(fieldPath)),
      );

  void updateIssueMessages() {/* ... */}
  
  // ...
}

/// フォームコントローラー
///
/// フォームフィールドのデータとフォーム管理
class ZFormController<T extends Object> {
  // ...
  
  late final _IssueMessagesNotifier _issueMessageNotifier;
  final _Debouncer _debouncer;
  final ZObject<T> zObject;
  ZFormFields _formFields;
  ZRes<T> _parseRes;
  int _submitCount = 0;

  ValueNotifier<String?> watchIssueMessageFor(String fieldPath) {/* ... */}

  void updateRawValue(String fieldPath, String? val) {/* ... */}

  String? getRawValue(String fieldPath) {/* ... */}

  ZRes<T> get parsedValue => _parseRes;

  void validateForm() {/* ... */}

  void submitForm() {/* ... */}

  void setTouched(String fieldPath) {/* ... */}

  // ...
}

ZodArtのエラーメッセージを多言語化する

  1. Riverpodを使って選択された言語のプロバイダを作る
  2. コードの自動生成を使う (dart run build_runner build)
  3. 言語切替ウィジェットを作成する
🎯 言語Providerのコード
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:zodart/zodart.dart';

part 'zodart_language.g.dart';

@riverpod
class ZodArtLanguage extends _$ZodArtLanguage {
  @override
  Language build() {
    // ZodArtの言語を日本語に設定
    const defaultLanguage = Language.ja;
    ZLocalizationContext.current = ZIssueLocalizationService(defaultLanguage);
    return defaultLanguage;
  }

  void setLanguage(Language language) {
    ZLocalizationContext.current = ZIssueLocalizationService(language);
    state = language;
  }
}
🎯 言語を選べるウィジェットのコード
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:zodart/zodart.dart';

import '../providers/zodart_language.dart';

class LanguageSelector extends ConsumerWidget {
  const LanguageSelector({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final language = ref.watch(zodArtLanguageProvider);

    return DropdownMenu(
      leadingIcon: const Icon(Icons.language),
      initialSelection: language,
      dropdownMenuEntries: Language.values
          .map((lang) => DropdownMenuEntry(value: lang, label: lang.name))
          .toList(),
      onSelected: (val) =>
          ref.read(zodArtLanguageProvider.notifier).setLanguage(val!),
    );
  }
}

コントローラーを扱えるウィジェット作成

コントローラーを利用できるTextFormFieldを作ります。

  • エラーメッセージが更新された時にだけrebuildするためにValueListenableBuilderを利用
  • 入力を維持するためTextEditingControllerを利用
  • ユーザーがフィールドから離れる時にバリデーション結果を表示するため、.setTouched()を利用

💡TextFormFieldの全引数をキープするべきですが、分かりやすくするためここで省けます。

🎯 ZFormFieldの疑似コード
class ZFormField extends StatefulWidget {
  // ...
  
  final String labelText;
  final String fieldPath;
  final ZFormController zFormController;

  @override
  State<ZFormField> createState() => _ZFormFieldState();
}

class _ZFormFieldState extends State<ZFormField> {
  late final TextEditingController _textEditingController;
  late final FocusNode _focusNode;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: widget.zFormController.watchIssueMessageFor(
        widget.fieldPath,
      ),
      builder: (context, issueMessage, _) {
        return TextFormField(
          controller: _textEditingController,
          focusNode: _focusNode,
          onChanged: (val) =>
              widget.zFormController.updateRawValue(widget.fieldPath, val),
          decoration: InputDecoration(
            labelText: widget.labelText,
            errorText: issueMessage,
          ),
        );
      },
    );
  }

  void onFocusChange() {
    if (!_focusNode.hasFocus) {
      widget.zFormController.setTouched(widget.fieldPath);
    }
  }

  @override
  void initState() { /* 必要なセットアップ */ }

  // ...
}

スキーマ拡張

上記のユーザー登録のスキーマをもっと面白くしましょう!

ZodArtはもちろんStringだけではなくて、様々な型が利用できます。ユーザーが生年月日を入力すると、ZodArtが現在時点で13歳以上かチェックします。💪🏻

🎯 スキーマのコード
import 'package:zodart/zodart.dart';

part 'user_schema.zodart.dart';
part 'user_schema.zodart.type.dart';

const usageAgeLimit = 13;

/// 指定した年齢以上かどうかを判定する関数を返します。
///
/// [years] 歳以上なら `true`、それ未満なら `false` を返します。
bool Function(DateTime) isNYearsOld(int years) => (DateTime birthDate) {
  final now = DateTime.now();
  final minimumBirthday = now.copyWith(year: now.year - years);
  return !birthDate.isAfter(minimumBirthday);
};

@ZodArt.generateNewClass(outputClassName: 'User')
abstract class UserSchema {
  static final schema = (
    firstName: ZString().trim().min(1).max(5),
    lastName: ZString().trim().min(1).max(5),
    email: ZString().trim().regex(
      r'^((?!\.)[\w\-_.]*[^.])(@\w+)(\.\w+(\.\w+)?[^.\W])$',
    ),
    birthDate: ZString().toDateTime().refine(
      isNYearsOld(usageAgeLimit),
      message: '$usageAgeLimit歳未満の方はご利用いただけません。',
    ),
  );

  static const z = _UserSchemaUtils();
  static final ZObject<User> zObject = z.zObject;
}

🎁 ご褒美のフォームバリデーション —— ここが ZodArt の強み!

ここまでで、ZodArt を使った実践的なフォームバリデーションの流れを見てきました。
最後に、なぜ ZodArt を使うとフォームバリデーションが “ご褒美のように” 快適になる理由は?

  • ✅ 型安全で UI とロジックがズレない安心感
  • ✅「パースファースト」だからこそ得られるシンプルさ
  • ✅ スキーマが Flutter から完全に独立している
  • ✅ エラーメッセージの多言語化が「ZodArt だけで完結」

全部のコードはGitHubにありますので、是非確認してください 🎯

🏁 まとめ: ZodArtの利点と次のステップ

この記事ではZodArt 🎯  ——  静的型推論、パースファーストのバリデーションパッケージを紹介しました。そして、シンプルなHooks利用から多言語対応、そして標準Flutter機能のみでの再利用まで、ZodArtを使ったフォームバリデーションのアプローチを段階的に見てきました。

✅ ZodArtを使ってみて分かった利点 (Pros)

  • 型安全なスキーマ定義により、フォームバリデーションを安心して書ける
  • バリデーションルールのコード(スキーマ)は拡張と再利用しやすい
  • パースファーストな設計で、入力値からドメインオブジェクトまで一気に処理できる
  • Flutter / Dart の標準との相性が良い(コード生成・型推論を最大限活用できる)
  • エラーのローカライズが柔軟で、UI との連携もしやすい
  • Hooks / Riverpod / バニラ Flutter など、どんなアーキテクチャにも組み込みやすい

⚠️ 現時点で感じた課題 (Cons)

  • フォーム向けの「即使える」ウィジェット群は、現状まだ存在しない
  • 複雑なフォームバリデーション(ネストされたフォーム構造や、各フィールドごとのバックエンド・サービス連携によるバリデーション)向けの専用ウィジェットは、まだない

簡単なフォームなら問題なくZodArtを利用できますが、“最初の選択肢” にするには、まだいくつかの機能追加とちょっとしたシンタックスシュガーが必要だと感じます。

次のステップ: ZodArt Formパッケージへのご意見を

今回作成した「ZodArtのみのミニパッケージ風」の実装を、フォームウィジェットを提供する本格的なZodArt Formパッケージの開発に発展させることを検討しています。

もし、このパッケージにご興味がありましたら、コメントでぜひお知らせください。皆様からのフィードバックが、開発の優先順位を決定する助けになります。

ZodArtで宣言的な型安全バリデーションを、Flutterにもたらしましょう! 💪🏻

80
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
80
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?