Complete Generator Recipes#
Three end-to-end, working examples. Each includes all required files.
Recipe 1: Simple File Transformer#
Transforms .txt files to .dart files. No analyzer needed — just reads/writes files.
lib/src/transformer.dart#
import 'package:build/build.dart';
class TxtToDartBuilder implements Builder {
const TxtToDartBuilder();
@override
Map<String, List<String>> get buildExtensions => const {
'.txt': ['.txt.dart'],
};
@override
Future<void> build(BuildStep buildStep) async {
final inputId = buildStep.inputId;
final outputId = inputId.addExtension('.dart');
// Read the text file
final content = await buildStep.readAsString(inputId);
// Generate a Dart file exposing the content as a string constant
final varName = inputId.pathSegments.last
.replaceAll('.txt', '')
.replaceAll('-', '_')
.replaceAll(' ', '_');
final output = '''
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
/// Content of ${inputId.path}
const String $varName = r\'\'\'
$content
\'\'\';
''';
await buildStep.writeAsString(outputId, output);
}
}
lib/builder.dart#
library;
import 'package:build/build.dart';
import 'src/transformer.dart';
Builder txtToDartBuilder(BuilderOptions options) => const TxtToDartBuilder();
build.yaml#
builders:
txt_to_dart:
import: "package:my_generator/builder.dart"
builder_factories: ["txtToDartBuilder"]
build_extensions: {".txt": [".txt.dart"]}
auto_apply: dependents
build_to: source
pubspec.yaml#
name: my_generator
version: 1.0.0
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
build: ^4.0.0
Recipe 2: Annotation-Driven Generator (String Concatenation)#
Generates fromJson / toJson methods for classes annotated with @Serializable. Uses the json_serializable pattern.
Step 1: Annotation Package#
my_annotations/pubspec.yaml
name: my_annotations
version: 1.0.0
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
meta: ^1.9.0
my_annotations/lib/src/serializable.dart
import 'package:meta/meta_meta.dart';
/// Apply to classes to generate fromJson/toJson code.
@Target({TargetKind.classType})
class Serializable {
/// Rename all fields using this strategy.
final FieldRename fieldRename;
/// Include null fields in toJson output.
final bool includeNull;
const Serializable({
this.fieldRename = FieldRename.none,
this.includeNull = true,
});
}
/// Apply to fields to override serialization behavior.
@Target({TargetKind.field, TargetKind.parameter})
class SerializableField {
/// Override the JSON key for this field.
final String? name;
/// Exclude this field from serialization.
final bool ignore;
const SerializableField({this.name, this.ignore = false});
}
enum FieldRename { none, snake, camel, pascal }
my_annotations/lib/my_annotations.dart
library my_annotations;
export 'src/serializable.dart';
Step 2: Generator Package#
my_generator/pubspec.yaml
name: my_generator
version: 1.0.0
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
build: ^4.0.0
analyzer: '>=8.0.0 <11.0.0'
source_gen: ^4.0.0
dart_style: ^3.0.0
my_annotations: ^1.0.0
dev_dependencies:
build_runner: ^2.10.0
build_test: ^3.3.0
test: ^1.25.0
my_generator/build.yaml
builders:
serializable:
import: "package:my_generator/builder.dart"
builder_factories: ["serializableBuilder"]
build_extensions: {".dart": ["serializable.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]
my_generator/lib/src/generator.dart
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:analyzer/dart/element/nullability_suffix.dart';
import 'package:build/build.dart';
import 'package:my_annotations/my_annotations.dart';
import 'package:source_gen/source_gen.dart';
const _fieldChecker = TypeChecker.typeNamed(SerializableField);
class SerializableGenerator extends GeneratorForAnnotation<Serializable> {
const SerializableGenerator();
@override
Iterable<String> generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) sync* {
if (element is! ClassElement) {
throw InvalidGenerationSourceError(
'@Serializable can only be applied to classes.',
element: element,
);
}
final fieldRenameStr = annotation.peek('fieldRename')?.revive().accessor;
final fieldRename = FieldRename.values.firstWhere(
(e) => e.name == fieldRenameStr,
orElse: () => FieldRename.none,
);
final includeNull = annotation.peek('includeNull')?.boolValue ?? true;
yield _generateFromJson(element);
yield _generateToJson(element, fieldRename, includeNull);
}
String _jsonKey(FieldElement field, FieldRename rename) {
// 1. Check for @SerializableField(name: ...)
final fieldAnnotation = _fieldChecker.firstAnnotationOf(field);
if (fieldAnnotation != null) {
final customName = fieldAnnotation.getField('name')?.toStringValue();
if (customName != null) return customName;
}
// 2. Apply rename strategy
final name = field.name!;
return switch (rename) {
FieldRename.snake => _toSnakeCase(name),
FieldRename.camel => name, // already camelCase
FieldRename.pascal => '${name[0].toUpperCase()}${name.substring(1)}',
FieldRename.none => name,
};
}
bool _shouldIgnore(FieldElement field) {
final annotation = _fieldChecker.firstAnnotationOf(field);
return annotation?.getField('ignore')?.toBoolValue() ?? false;
}
List<FieldElement> _getFields(ClassElement element) {
return element.fields
.where((f) => !f.isStatic && !f.isSynthetic && f.isPublic)
.where((f) => !_shouldIgnore(f))
.toList();
}
String _generateFromJson(ClassElement element) {
final className = element.name;
final fields = _getFields(element);
final args = fields.map((field) {
final key = _jsonKey(field, FieldRename.none);
final type = field.type;
final isNullable = type.nullabilitySuffix == NullabilitySuffix.question;
final typeName = type.getDisplayString(withNullability: false);
// Basic type deserialization
final jsonAccess = "json['$key']";
final deser = _deserialize(type, jsonAccess);
return '${field.name}: $deser,';
}).join('\n ');
return '''
$className _\$${className}FromJson(Map<String, dynamic> json) => $className(
$args
);''';
}
String _generateToJson(
ClassElement element,
FieldRename fieldRename,
bool includeNull,
) {
final className = element.name;
final fields = _getFields(element);
final entries = fields.map((field) {
final key = _jsonKey(field, fieldRename);
final isNullable = field.type.nullabilitySuffix == NullabilitySuffix.question;
final value = _serialize(field.type, 'instance.${field.name}');
if (!includeNull && isNullable) {
return "if (instance.${field.name} != null) '$key': $value,";
}
return "'$key': $value,";
}).join('\n ');
return '''
Map<String, dynamic> _\$${className}ToJson($className instance) => <String, dynamic>{
$entries
};''';
}
String _deserialize(DartType type, String expression) {
final baseType = type.promoteToNonNull;
final typeName = baseType.getDisplayString(withNullability: false);
final isNullable = type.nullabilitySuffix == NullabilitySuffix.question;
final nullCheck = isNullable ? '?' : '';
if (baseType is InterfaceType) {
if (baseType.isDartCoreString) return '$expression as String$nullCheck';
if (baseType.isDartCoreInt) return '($expression as num$nullCheck)$nullCheck.toInt()';
if (baseType.isDartCoreDouble) return '($expression as num$nullCheck)$nullCheck.toDouble()';
if (baseType.isDartCoreBool) return '$expression as bool$nullCheck';
if (baseType.isDartCoreList) {
final inner = baseType.typeArguments.firstOrNull;
if (inner != null) {
return '($expression as List<dynamic>$nullCheck)$nullCheck'
'.map((e) => ${_deserialize(inner, 'e')}).toList()';
}
return '$expression as List<dynamic>$nullCheck';
}
if (baseType.isDartCoreMap) {
return '($expression as Map<String, dynamic>$nullCheck)';
}
// Assume it has fromJson
if (isNullable) {
return '$expression == null ? null : $typeName.fromJson($expression as Map<String, dynamic>)';
}
return '$typeName.fromJson($expression as Map<String, dynamic>)';
}
return '$expression as $typeName$nullCheck';
}
String _serialize(DartType type, String expression) {
final baseType = type.promoteToNonNull;
final isNullable = type.nullabilitySuffix == NullabilitySuffix.question;
if (baseType is InterfaceType) {
if (baseType.isDartCoreString ||
baseType.isDartCoreInt ||
baseType.isDartCoreDouble ||
baseType.isDartCoreBool) {
return expression;
}
if (baseType.isDartCoreList) {
final inner = baseType.typeArguments.firstOrNull;
if (inner != null && !_isPrimitive(inner)) {
final q = isNullable ? '?' : '';
return '$expression$q.map((e) => ${_serialize(inner, 'e')}).toList()';
}
return expression;
}
if (baseType.isDartCoreMap) return expression;
// Assume it has toJson
final q = isNullable ? '?' : '';
return '$expression$q.toJson()';
}
return expression;
}
bool _isPrimitive(DartType type) {
if (type is! InterfaceType) return false;
return type.isDartCoreString ||
type.isDartCoreInt ||
type.isDartCoreDouble ||
type.isDartCoreBool;
}
String _toSnakeCase(String camelCase) {
return camelCase.replaceAllMapped(
RegExp(r'[A-Z]'),
(m) => '_${m.group(0)!.toLowerCase()}',
);
}
}
my_generator/lib/builder.dart
library;
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'src/generator.dart';
Builder serializableBuilder(BuilderOptions options) =>
SharedPartBuilder([const SerializableGenerator()], 'serializable');
Step 3: User Code#
User's pubspec.yaml
dependencies:
my_annotations: ^1.0.0
dev_dependencies:
my_generator: ^1.0.0
build_runner: ^2.10.0
User's source file
// lib/user.dart
import 'package:my_annotations/my_annotations.dart';
part 'user.g.dart';
@Serializable(fieldRename: FieldRename.snake, includeNull: false)
class User {
final String id;
final String firstName;
final String? email;
@SerializableField(name: 'family_name')
final String lastName;
@SerializableField(ignore: true)
final String internalSecret;
const User({
required this.id,
required this.firstName,
required this.lastName,
this.email,
required this.internalSecret,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
Generated output (lib/user.g.dart)
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
part of 'user.dart';
User _$UserFromJson(Map<String, dynamic> json) => User(
id: json['id'] as String,
firstName: json['first_name'] as String,
lastName: json['family_name'] as String,
email: json['email'] as String?,
internalSecret: json['internal_secret'] as String,
);
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
'id': instance.id,
'first_name': instance.firstName,
'family_name': instance.lastName,
if (instance.email != null) 'email': instance.email,
};
Recipe 3: code_builder Approach (Retrofit Pattern)#
Generates a concrete implementation class for an abstract API interface. Uses code_builder
for type-safe code AST.
Annotation#
@Target({TargetKind.classType})
class ApiClient {
final String baseUrl;
const ApiClient({this.baseUrl = ''});
}
@Target({TargetKind.method})
class GET {
final String path;
const GET(this.path);
}
Generator#
import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
import 'package:code_builder/code_builder.dart';
import 'package:dart_style/dart_style.dart';
import 'package:source_gen/source_gen.dart';
const _getChecker = TypeChecker.typeNamed(GET);
class ApiClientGenerator extends GeneratorForAnnotation<ApiClient> {
@override
String generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
if (element is! ClassElement) {
throw InvalidGenerationSourceError(
'@ApiClient can only be applied to abstract classes.',
element: element,
);
}
final baseUrl = annotation.peek('baseUrl')?.stringValue ?? '';
final className = '_\$${element.name}';
final implClass = Class((c) {
c
..name = className
..extend = refer(element.name)
..fields.addAll([
Field((f) => f
..name = '_baseUrl'
..type = refer('String')
..modifier = FieldModifier.final$),
Field((f) => f
..name = '_client'
..type = refer('http.Client')
..modifier = FieldModifier.final$),
])
..constructors.add(Constructor((con) {
con
..requiredParameters.addAll([
Parameter((p) => p
..name = '_client'
..toThis = true),
])
..optionalParameters.add(
Parameter((p) => p
..name = 'baseUrl'
..named = true
..type = refer('String')
..defaultTo = literalString(baseUrl).code),
)
..initializers.add(Code("_baseUrl = baseUrl"));
}))
..methods.addAll(_generateMethods(element));
});
final emitter = DartEmitter(useNullSafetySyntax: true);
return DartFormatter(
languageVersion: DartFormatter.latestLanguageVersion,
).format(implClass.accept(emitter).toString());
}
Iterable<Method> _generateMethods(ClassElement element) {
return element.methods
.where((m) => m.isAbstract)
.where((m) => _getChecker.hasAnnotationOf(m))
.map(_generateMethod);
}
Method _generateMethod(MethodElement method) {
final getAnnotation = _getChecker.firstAnnotationOf(method)!;
final path = getAnnotation.getField('path')?.toStringValue() ?? '/';
// Detect return type (must be Future<Something>)
final returnType = method.returnType;
final returnTypeName = returnType.getDisplayString(withNullability: true);
return Method((m) {
m
..name = method.name
..returns = refer(returnTypeName)
..modifier = MethodModifier.async
..annotations.add(refer('override'))
..requiredParameters.addAll(
method.formalParameters
.where((p) => p.isRequiredPositional)
.map((p) => Parameter((param) {
param
..name = p.name!
..type = refer(
p.type.getDisplayString(withNullability: true),
);
})),
)
..body = Block.of([
// final uri = Uri.parse('$_baseUrl$path');
declareFinal('uri')
.assign(
refer('Uri').property('parse').call([
literalString('\$_baseUrl$path'),
]),
)
.statement,
// final response = await _client.get(uri);
declareFinal('response')
.assign(
refer('_client')
.property('get')
.call([refer('uri')])
.awaited,
)
.statement,
// return SomeType.fromJson(jsonDecode(response.body));
const Code('return SomeType.fromJson(jsonDecode(response.body));'),
]);
});
}
}
lib/builder.dart#
library;
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'src/generator.dart';
Builder apiClientBuilder(BuilderOptions options) =>
SharedPartBuilder([ApiClientGenerator()], 'api_client');
build.yaml#
builders:
api_client:
import: "package:my_generator/builder.dart"
builder_factories: ["apiClientBuilder"]
build_extensions: {".dart": [".api_client.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]
Recipe 4: Package-Level Generator (Runs Once Per Package)#
Generates a single file aggregating all annotated classes in a package.
class AggregatorBuilder implements Builder {
@override
Map<String, List<String>> get buildExtensions => const {
r'$package$': ['lib/generated/all_models.dart'],
};
@override
Future<void> build(BuildStep buildStep) async {
final outputId = AssetId(
buildStep.inputId.package,
'lib/generated/all_models.dart',
);
// Find all dart files in lib/
final modelClasses = <ClassElement>[];
await for (final assetId in buildStep.findAssets(Glob('lib/**.dart'))) {
// Only process non-generated files
if (assetId.path.endsWith('.g.dart')) continue;
if (!await buildStep.resolver.isLibrary(assetId)) continue;
final library = await buildStep.resolver.libraryFor(assetId);
for (final element in library.topLevelElements.whereType<ClassElement>()) {
if (myChecker.hasAnnotationOf(element)) {
modelClasses.add(element);
}
}
}
// Generate the aggregator file
final buffer = StringBuffer();
buffer.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND');
buffer.writeln('');
buffer.writeln("import 'package:${buildStep.inputId.package}/models.dart';");
buffer.writeln('');
buffer.writeln('const allModelNames = [');
for (final cls in modelClasses) {
buffer.writeln(" '${cls.name}',");
}
buffer.writeln('];');
await buildStep.writeAsString(outputId, buffer.toString());
}
}
# build.yaml
builders:
aggregator:
import: "package:my_generator/builder.dart"
builder_factories: ["aggregatorBuilder"]
build_extensions: {r"$package$": ["lib/generated/all_models.dart"]}
auto_apply: dependents
build_to: source
is_optional: false