Skip to content
Blog

Dart source_gen Utilities: Generators, LibraryReader, TypeChecker, and build_test

Write and ship a source generator: Generator and GeneratorForAnnotation, ConstantReader, TypeChecker, LibraryReader, buildStep output, build_runner, and tests.

Published on • November 10, 2026

AI Assistant

Hand-writing toJson, fromJson, and toString for every model is repetitive and drifts out of sync the moment a field changes. source_gen (current release 4.3.1, published by tools.dart.dev) provides a framework for writing Builders on top of build plus a convention for keeping generated code in part files — here are the utilities you actually use while writing a generator, and the pitfalls that break them.

Generator vs GeneratorForAnnotation

Generator is the low-level contract: generate(LibraryReader library, BuildStep buildStep) runs once per input library and returns a string (or null/empty to emit nothing):

class DocCommentGenerator extends Generator {
  @override
  Future<String> generate(LibraryReader library, BuildStep buildStep) =>
      library.allElements
          .where((e) => e.hasDocumentationComment)
          .map((e) => '// ${e.name} is documented.')
          .join('\n');
}

Most real generators extend GeneratorForAnnotation<T> instead. It walks the library for elements annotated with T and hands each one to you:

class JsonModelGenerator extends GeneratorForAnnotation<JsonModel> {
  @override
  Future<String> generateForAnnotatedElement(
      Element element, ConstantReader annotation, BuildStep buildStep) {
    if (element is! ClassElement) return '';
    return 'String toJson_${element.name}() => \'{}\';';
  }
}

Results from every annotated element are joined and deduplicated, so a shared helper emitted by several elements collapses into one definition. Return null (or an empty string) when a library has nothing for you; in 4.x, directives have their own generateForAnnotatedDirective hook.

Annotations, TypeChecker, and LibraryReader

ConstantReader wraps the annotation instance so you never touch the analyzer’s constant API directly, and TypeChecker decides what matches. Since 4.x, GeneratorForAnnotation builds its checker from TypeChecker.typeNamed(T, inPackage: ..., inSdk: ...), which matches by type name — pass inPackage so a same-named class from a different package cannot sneak in:

final value = annotation.read('defaultValue');
if (!value.isNull) use(value.literalValue);   // int, String, bool, List...
final uri = annotation.revive();              // constructor name + arguments

class _StrictJsonModel extends GeneratorForAnnotation<JsonModel> {
  const _StrictJsonModel() : super(inPackage: 'my_app_models');
}

LibraryReader is the convenience wrapper over LibraryElement you navigate with:

final library = LibraryReader(element);
final annotated = library.annotatedWith(checker); // AnnotatedElement pairs
final all = library.allElements;                  // classes, fields, methods
final type = library.findType('MyModel');         // follows export directives

findType is the one to remember: LibraryElement.getType('Generator') returns null when the type arrives via an export directive, while findType traverses exports and finds it. Note the 4.x split — annotatedWith covers elements, libraryDirectivesAnnotatedWith covers library and part directives.

Builders, buildStep, and build_runner

Your generator returns strings; the Builder decides where they land:

  • SharedPartBuilder writes .some_cool_builder.g.part into the build cache; source_gen:combining_builder merges pieces into the .g.dart referenced by your part directive. It is the only correct owner of the .g.dart extension.
  • PartBuilder writes to an extension unique to your package.
  • LibraryBuilder writes a standalone library consumers import — exactly one generator allowed.

Expose it from a builder.dart entry point and declare it in build.yaml:

builders:
  some_cool_builder:
    import: "package:this_package/builder.dart"
    builder_factories: ["someCoolBuilder"]
    build_extensions: {".dart": [".some_cool_builder.g.part"]}
    auto_apply: dependents
    build_to: cache
    applies_builders: ["source_gen:combining_builder"]

The BuildStep you receive is the invalidation contract: resolve elements through buildStep.resolver and read any other asset with buildStep.canRead/readAsString, and build re-runs your builder exactly when those inputs change. Then run it:

dart run build_runner build --delete-conflicting-outputs
dart run build_runner watch --delete-conflicting-outputs

Add build_runner and source_gen to dev_dependencies when generating only inside your own project; ship a source_gen dependency when your builder goes to others, because builder options are part of your public API.

Testing with build_test

Do not test generators by running a full build — build_test gives you an in-memory builder harness:

import 'package:build_test/build_test.dart';
import 'package:test/test.dart';

void main() {
  test('generates a toJson method', () async {
    await testBuilder(
      LibraryBuilder(JsonModelGenerator()),
      {'a|lib/model.dart': '@JsonModel() class User {}'},
      outputs: {'a|lib/model.g.dart': decodedMatches(contains('toJson_User'))},
    );
  });
}

The source_gen repo’s own tests use exactly this pattern, including asserting that a library with no interesting annotations produces no output at all.

Pitfalls: part files and invalidation

The failures are almost always structural rather than algorithmic:

  • The part directive must match the output. A SharedPartBuilder output is only usable when the input declares part 'x.g.dart'; — forget it and the build silently writes a file nothing imports.
  • Never write .g.dart yourself or with another builder. The combining builder owns it; a second writer produces conflicting outputs that --delete-conflicting-outputs papers over.
  • Generated code is not hand-editable. Customize the default header with the combining_builder header option, silence lints with ignore_for_file, and prepend license text with preamble.
  • Inputs are tracked, dependencies are not. Changing an annotated class re-runs the builder because you resolved it through the BuildStep, but reaching out to undeclared files or packages leaves stale output until a clean build.

Takeaway: Build generators on GeneratorForAnnotation<T> with ConstantReader for values, TypeChecker.typeNamed(..., inPackage:) for matching, and LibraryReader to navigate exports; ship them as a SharedPartBuilder wired to combining_builder, and lock behavior down with build_test’s testBuilder so part-file and invalidation mistakes surface in CI instead of production.

Sources