Code Generation Approaches#
Two proven strategies from production generators: string concatenation (json_serializable) and code_builder AST (retrofit).
Overview#
| Aspect | String Concatenation | code_builder AST |
|---|---|---|
| Approach | Build Dart code as strings | Build a Dart AST, then emit |
| Used by | json_serializable | retrofit_generator |
| Learning curve | Low | Medium |
| Type safety | None — runtime errors if malformed | High — compile-time structure |
| Import management | Manual | Via Allocator (automatic) |
| Readability | High (see exactly what output looks like) | Medium (builder DSL) |
| Flexibility | Total | Constrained to AST nodes |
| Best for | Class-level code generation | Complex classes with many methods |
Approach 1: String Concatenation#
Used by json_serializable. Simple, direct, readable.
Basic Pattern#
@override
String generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
final classElement = element as ClassElement;
final className = classElement.name;
return '''
${className} _\$${className}FromJson(Map<String, dynamic> json) {
return ${className}(
${_buildFields(classElement)}
);
}
Map<String, dynamic> _\$${className}ToJson(${className} instance) => <String, dynamic>{
${_buildToJsonFields(classElement)}
};
''';
}
StringBuffer for Complex Output#
When output has conditional sections, use StringBuffer:
String _generateClass(ClassElement element) {
final buffer = StringBuffer();
final name = element.name;
buffer.writeln('class _\$${name}Impl implements $name {');
// Fields
for (final field in element.fields) {
if (field.isStatic) continue;
buffer.writeln(' @override');
buffer.writeln(' final ${_typeString(field.type)} ${field.name};');
}
buffer.writeln();
// Constructor
buffer.write(' const _\$${name}Impl({');
buffer.writeln(element.fields
.where((f) => !f.isStatic)
.map((f) => 'required this.${f.name}')
.join(', '));
buffer.writeln('});');
buffer.writeln('}');
return buffer.toString();
}
Iterable with sync* (json_serializable pattern) #
@override
Iterable<String> generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) sync* {
final classElement = element as ClassElement;
// Always generate fromJson
yield _generateFromJson(classElement, annotation);
// Conditionally generate toJson
if (annotation.peek('createToJson')?.boolValue ?? true) {
yield* _generateToJson(classElement, annotation);
}
// Generate field map if requested
if (annotation.peek('createFieldMap')?.boolValue ?? false) {
yield _generateFieldMap(classElement);
}
}
Type Display Strings#
Get the Dart type name as a string for interpolation:
String _typeString(DartType type, {bool withNullability = true}) =>
type.getDisplayString(withNullability: withNullability);
// Examples:
// String → 'String'
// String? → 'String?'
// List<int> → 'List<int>'
// Map<String, dynamic> → 'Map<String, dynamic>'
Handling Generics in Output#
String _generateFromJson(ClassElement element, ConstantReader annotation) {
final typeParams = element.typeParameters;
final genericArgs = typeParams.isEmpty
? ''
: '<${typeParams.map((p) => p.name).join(', ')}>';
final genericConstraints = typeParams.isEmpty
? ''
: '<${typeParams.map((p) {
final bound = p.bound;
return bound == null
? p.name!
: '${p.name} extends ${_typeString(bound)}';
}).join(', ')}>';
return '''
${element.name}$genericArgs _\$${element.name}FromJson$genericConstraints(
Map<String, dynamic> json) {
return ${element.name}$genericArgs(
${_buildConstructorArgs(element)}
);
}
''';
}
Approach 2: code_builder AST#
Used by retrofit_generator. Builds a typed AST, then emits as Dart code.
Core Imports#
import 'package:code_builder/code_builder.dart';
import 'package:dart_style/dart_style.dart';
Class Builder#
final myClass = Class((c) {
c
..name = '_\$MyClass'
..types.addAll(['T'].map(refer)) // Generic params
..extend = refer('BaseClass') // extends BaseClass
..implements.add(refer('MyInterface')) // implements MyInterface
..mixins.add(refer('MyMixin')) // with MyMixin
..annotations.add(refer('immutable')) // @immutable
..fields.addAll([
Field((f) => f
..name = '_dio'
..type = refer('Dio')
..modifier = FieldModifier.final$ // final
..late = false), // not late
Field((f) => f
..name = 'baseUrl'
..type = refer('String')
..modifier = FieldModifier.var$ // var
..assignment = literalString('/api').code), // = '/api'
])
..constructors.add(
Constructor((con) {
con
..name = null // null = default ctor
..constant = false
..factory = false
..requiredParameters.add(
Parameter((p) => p
..name = '_dio'
..toThis = true // this._dio
..type = refer('Dio')),
)
..optionalParameters.add(
Parameter((p) => p
..name = 'baseUrl'
..named = true // {baseUrl}
..toThis = true
..defaultTo = literalString('').code),
)
..body = const Code(''); // empty body
}),
)
..methods.addAll([
Method((m) {
m
..name = 'getUser'
..returns = refer('Future<User>')
..modifier = MethodModifier.async
..annotations.add(refer('override'))
..requiredParameters.add(
Parameter((p) => p
..name = 'id'
..type = refer('String')),
)
..body = Code('''
final response = await _dio.get('/users/\$id');
return User.fromJson(response.data);
''');
}),
]);
});
Referring to Types#
// Simple reference (no import needed if in same file or already imported)
refer('String')
refer('int')
refer('List<String>') // Be careful — this is just a string reference
refer('Future<void>')
// Reference with explicit import (Allocator handles deduplication)
refer('Dio', 'package:dio/dio.dart')
refer('JsonSerializable', 'package:json_annotation/json_annotation.dart')
// Reference a symbol
refer('MyClass') // no import needed if type is in scope
Method Body: Code vs Block#
// Code: raw Dart code string
Method((m) => m
..body = Code('''
final options = Options(method: 'GET', path: '/users');
final result = await _dio.fetch<Map<String, dynamic>>(options);
return User.fromJson(result.data!);
'''))
// Block: structured expressions (more verbose but type-checked)
Method((m) => m
..body = Block.of([
declareFinal('options').assign(
refer('Options').newInstance([], {'method': literalString('GET')}),
).statement,
refer('_dio').property('get').call([literalString('/users')]).awaited
.assignVar('result').statement,
]))
Common Expression Builders#
// Literals
literalString('hello') // 'hello'
literalString('hello', raw: true) // r'hello'
literalNum(42) // 42
literal(3.14) // 3.14
literalTrue // true
literalFalse // false
literalNull // null
literalList([literalString('a'), literalString('b')]) // ['a', 'b']
literalMap({'key': literalString('value')}) // {'key': 'value'}
// Variable declarations
declareFinal('result') // final result
declareFinal('result').assign(expr).statement // final result = expr;
declareVar('result') // var result
declareType('String', 'name').assign(literalString('x')).statement // String name = 'x';
// Method calls
refer('myFunction').call(
[literalString('arg1')], // positional args
{'named': literalNum(42)}, // named args
[refer('T')], // type args
)
// → myFunction<T>('arg1', named: 42)
// Property access
refer('instance').property('field') // instance.field
// Cascade
refer('list')
.cascade('add').call([literalNum(1)])
.cascade('add').call([literalNum(2)])
// Await
refer('future').awaited
// Return
refer('value').returned.statement // return value;
// If/else, ternary — use Code for complex control flow
const Code('if (condition) { ... } else { ... }')
Field Modifiers#
FieldModifier.final$ // final
FieldModifier.var$ // var (default)
FieldModifier.constant // const
Method Modifiers#
MethodModifier.async // async
MethodModifier.asyncStar // async*
MethodModifier.syncStar // sync*
Emitting Code#
// Create emitter
final emitter = DartEmitter(
useNullSafetySyntax: true, // Use ? and ! syntax
allocator: Allocator(), // For auto-managing imports
);
// Emit a spec (Class, Method, Field, etc.)
final code = myClass.accept(emitter).toString();
// Emit a library
final lib = Library((l) {
l
..directives.addAll([
Directive.import('package:dio/dio.dart'),
])
..body.addAll([myClass]);
});
final libCode = lib.accept(emitter).toString();
Formatting#
Always format the final output — generated code is often unformatted:
import 'package:dart_style/dart_style.dart';
String _formatCode(String code) {
return DartFormatter(
languageVersion: DartFormatter.latestLanguageVersion,
).format(code);
}
// If the code might be a snippet (not a complete file), catch FormatException:
String _formatOrReturn(String code) {
try {
return DartFormatter(
languageVersion: DartFormatter.latestLanguageVersion,
).format(code);
} on FormatterException {
return code; // Return unformatted if it can't be formatted
}
}
The DartFormatter.latestLanguageVersion is a (major, minor) record. SharedPartBuilder also passes the version to a custom
formatOutput function.
Part Files vs Library Files#
Part File (SharedPartBuilder / PartBuilder)#
The generated code is a "part" of the source library:
// source: lib/model.dart
import 'package:my_annotations/my_annotations.dart';
part 'model.g.dart'; // Required in source file
@MyAnnotation()
class Model { ... }
// generated: lib/model.g.dart (or lib/model.my_builder.g.part combined)
part of 'model.dart'; // Generated header
// GENERATED CODE - DO NOT MODIFY BY HAND
Model _$ModelFromJson(Map<String, dynamic> json) { ... }
Map<String, dynamic> _$ModelToJson(Model instance) => { ... };
Part files share the same namespace as the host library — they can access private members.
Standalone Library (LibraryBuilder)#
The generated file is a complete, independent library:
// generated: lib/model.generated.dart
// GENERATED CODE - DO NOT MODIFY BY HAND
import 'model.dart'; // Must import what it uses
class ModelMapper { ... }
Mixed Output (dart_mappable pattern)#
dart_mappable outputs two files: .mapper.dart (mapper class) and .init.dart
(initializer). These are standalone files (build_to: source), not parts.
Formatting in Context#
In SharedPartBuilder's formatOutput#
Builder myBuilder(BuilderOptions options) => SharedPartBuilder(
[MyGenerator()],
'my_builder',
formatOutput: (String code, LanguageVersion version) {
return DartFormatter(languageVersion: version).format(code);
},
);
In a custom Builder#
Future<void> build(BuildStep buildStep) async {
final code = _generate();
// Format before writing
final formatted = DartFormatter(
languageVersion: DartFormatter.latestLanguageVersion,
).format(code);
await buildStep.writeAsString(
buildStep.inputId.changeExtension('.g.dart'),
formatted,
);
}
Adding File Header Comments#
Always include a generated file header:
const _header = '''
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
''';
String _buildOutput(String generatedCode) => '$_header\n$generatedCode';
source_gen's SharedPartBuilder automatically adds:
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
part of '...';
For standalone builders, add it manually.
Avoiding Common Output Issues#
Escaping Dollar Signs in String Literals#
When generating code with $ interpolation inside a Dart string literal:
// In generator source:
return '''
String get label => '\${instance.name}';
''';
// Output: String get label => '${instance.name}';
// Or use raw strings when no interpolation needed:
return r'''
void _$init() { }
''';
// Output: void _$init() { }
Handling Optional Nullability in Output#
String _fieldDeclaration(FieldElement field) {
final type = field.type.getDisplayString(withNullability: true);
final name = field.name;
return ' final $type $name;';
}
// String field → ' final String name;'
// int? field → ' final int? count;'
Generating Correct Generics#
String _genericArgs(ClassElement element) {
if (element.typeParameters.isEmpty) return '';
return '<${element.typeParameters.map((p) => p.name).join(', ')}>';
}
String _genericConstraints(ClassElement element) {
if (element.typeParameters.isEmpty) return '';
return '<${element.typeParameters.map((p) {
final bound = p.bound;
return bound == null ? p.name! : '${p.name} extends ${bound.getDisplayString(withNullability: true)}';
}).join(', ')}>';
}
// Usage:
// class MyMapper${_genericConstraints(element)} { ... }
// MyClass${_genericArgs(element)} _fromJson(...) { ... }