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:
SharedPartBuilderwrites.some_cool_builder.g.partinto the build cache;source_gen:combining_buildermerges pieces into the.g.dartreferenced by yourpartdirective. It is the only correct owner of the.g.dartextension.PartBuilderwrites to an extension unique to your package.LibraryBuilderwrites a standalone library consumersimport— 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
partdirective must match the output. ASharedPartBuilderoutput is only usable when the input declarespart 'x.g.dart';— forget it and the build silently writes a file nothing imports. - Never write
.g.dartyourself or with another builder. The combining builder owns it; a second writer produces conflicting outputs that--delete-conflicting-outputspapers over. - Generated code is not hand-editable. Customize the default header with the
combining_builderheaderoption, silence lints withignore_for_file, and prepend license text withpreamble. - 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
- https://pub.dev/packages/source_gen
- https://github.com/dart-lang/source_gen
- https://pub.dev/documentation/source_gen/latest/source_gen/GeneratorForAnnotation-class.html
- https://pub.dev/documentation/source_gen/latest/source_gen/Generator-class.html
- https://pub.dev/packages/build_runner
- https://pub.dev/packages/build_test