LogoDart Code Generation

Code Generation Approaches#

Two proven strategies from production generators: string concatenation (json_serializable) and code_builder AST (retrofit).


Overview#

AspectString Concatenationcode_builder AST
ApproachBuild Dart code as stringsBuild a Dart AST, then emit
Used byjson_serializableretrofit_generator
Learning curveLowMedium
Type safety None — runtime errors if malformed High — compile-time structure
Import managementManualVia Allocator (automatic)
ReadabilityHigh (see exactly what output looks like)Medium (builder DSL)
FlexibilityTotalConstrained to AST nodes
Best forClass-level code generationComplex 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(...) { ... }