Dart Code Generation — Comprehensive Guide

MJ Studio
MJ Studio
Published in
18 min readOct 17, 2023

--

Generate your auto generated code with build, build_config, analyzer, source_gen

Code Generation Monster

Korean Translated Article

Overview

This article explains how to perform static code generation in the Dart language using build_runner based on the build package, mixing concepts and examples. The main topics covered are as follows:

- build, build_config, build_runner, source_gen

In other words, our aim is to investigate how packages like freezed or flutter_gen were developed.

Examples used in this article can be viewed at the Github Repository.

Code Generation?

When a package provides a script, there can generally be two cases.

  1. Generation is performed using dart run build_runner build.
  2. Generation is performed using dart run <package>:<command?>.

The former category includes packages like freezed, while the latter includes packages like flutter_native_splash.

In the case of the latter, it’s possible to create the package without much prior knowledge (though this doesn’t necessarily mean it’s easy).

If you look at the pubspec.yaml of the flutter_native_splash package, you won't find packages like build, build_runner, or source_gen included.

Instead, they only provide Dart files such as create.dart and remove.dart in the project's bin directory, which can be directly executed by the Dart compiler.

However, when examining the pubspec.yaml of the freezed package, you can see that it is replete with packages like analyzer, build, build_config, source_gen, build_test, and build_runner in both the dependencies and dev_dependencies. These are the topics we aim to cover in this article.

Why we should use build package?

In cases where you want to create a splash screen or a launcher icon in Flutter, structuring the package to include Dart scripts in the bin directory and functioning like a CLI can be a proper option.

However, the build package is needed in the following situations:

  1. When you need to incrementally access the source code and make mutations continuously.
  2. When you have to directly access Dart’s AST (Abstract Syntax Tree, Syntactic) and Element (Semantic Tree) to perform Code Generation based on Dart syntax.

Additionally, if you want to insert any steps that act like interceptors or hooks into Dart build systems, you will need the build package.

Package we should know

When you visit GitHub, you can find various packages along with brief descriptions of them.

Generally, to perform Code Generation, you should be familiar with the following:

  • build, build_config, build_runner, build_test, analyzer

There’s one more essential package to add to this list.

source_gen is a utility created using tools like build or analyzer. It provides a more developer-friendly Code Generation API by predefining and supplying other types of Builders (PartBuilder or LibraryBuilder) for the Builder.

source_gen is a utility created using other packages like build or analyzer. It predefines different types of Builders (like PartBuilder or LibraryBuilder) and offers a more developer-friendly Code Generation API.

How to read this article?

Rather than being entirely theory-centric or entirely example-centric, I aim to proceed by appropriately mixing examples and theory depending on the situation.

Also, for well-maded examples, I always refer to json_serializable and freezed will attach links accordingly.

Let's get started.

Example 1 — CopyBuilder

First, let’s create an example where for all .txt files in the lib folder, a copied text with the content gets created next to it with the filename ending in .copy.txt.

1. Create Dart CLI Project

Create Dart CLI project code_generation,(name can differ.).

Add build_config and build_runner into dev_dependencies of pubspec.yaml.

Add build into dependencies of pubspec.yaml.

name: code_generation  
description: A sample command-line application.
version: 1.0.0
# repository: https://github.com/my_org/my_repo

environment:
sdk: ^3.1.3

# Add regular dependencies here.
dependencies:
build: ^2.4.1
# path: ^1.8.0

dev_dependencies:
build_config: ^1.1.1
build_runner: ^2.4.6
lints: ^2.1.1
test: ^1.24.8

2. Configurebuild.yaml

The build.yaml is a configuration file that the build_config library parses and uses to customize the build process. Detailed information related to this can be found in the Docs, but you might not want to read it right away.

Therefore, you can read the above-mentioned document later. For now, let’s prepare a build.yaml file in the project root.

I've left brief explanations as comments.

Let’s assume the package has the name code_generation.

targets: # Target group that builders are applied
$default: # At least one target should use the package name,
# and in lieu of that, you can define the default behavior using $default.
builders: # Declare the builders to be applied to the current target (builders defined below).
code_generation|copyBuilder: # Set as $packageName|$builderName
generate_for: # Files to which the builder will be applied, using a glob pattern.
- lib/*
enabled: True # Whether the builder will be applied or not. If not defined, it follows the auto_apply attribute of the builder.
# options: declare BuilderOptions

builders: # Declare Builder
copyBuilder: # The name of builder is `copyBuilder`.
import: 'package:code_generation/code_generation.dart' # The URI that needs to be imported for the builder to run.
builder_factories: [ 'copyBuilder' ] # Top-level Builder creation functions that can be brought in from the import.
build_extensions: # List of extensions for the inputs related to the outputs produced by the Builder.
.txt:
- .copy.txt
build_to: source # source (creates output right next to input) | cache (cache directory)
auto_apply: dependents # none | dependents (applied to direct dependency) | all_packages | root_package

Create an entry file inside the lib directory

Since the package name is code_generation, create code_generation.dart directly under the lib directory.

// code_generation.dart

import 'package:build/build.dart';

import 'copy_builder.dart';

Builder copyBuilder(BuilderOptions options) => CopyBuilder();

As you can see, the name copyBuilder should be the same as the name provided to builder_factories in the build.yaml.

To avoid confusion, make sure that the name of the builder in the builders defined in the yaml file and the name entering builder_factories are exactly the same.

The function to be included here should be defined to match Builder <builder name>(BuilderOptions options).

4. CopyBuilder

// copy_builder.dart

import 'dart:async';

import 'package:build/build.dart';

class CopyBuilder implements Builder {
@override
FutureOr<void> build(BuildStep buildStep) async {
AssetId inputId = buildStep.inputId;

var copyAssetId = inputId.changeExtension('.copy.txt');
var contents = await buildStep.readAsString(inputId);

await buildStep.writeAsString(copyAssetId, contents);
}

@override
Map<String, List<String>> get buildExtensions => {
'.txt': ['.copy.txt'],
};
}

Create a custom builder class that implements the Builder.

buildExtensions plays the same role as build_extensions defined in build.yaml, and the two should be identical.

Since we decided to convert .txt to .copy.txt, we write it as above.

Looking at the build function, AssetId represents a single file, and it's an object containing details like the file's extension, path, and content.

Thus, we just need to read and write this file. The names of the functions in the code are intuitive, so I’ll skip the explanations.

5. Generate code using build_runner

After creating any .txt file in the lib, let's run dart run build_runner build.

You can confirm that it has been successfully copied as .copy.txt.

Example 2 — Get options from user

Let’s try to create it like a package that actual users can use.

The things to do in this example are as follows.

  • Retrieve the set options and modify the Generation logic.

1. Set default option values in the build.yaml file (Optional).

targets: 
$default:
builders:
code_generation|copyBuilder:
generate_for:
- lib/*
enabled: True
options:
name: MJ

The options defined in targets - builders in build.yaml are included in BuilderOptions.config.

Pass the options to CopyBuilder as follows.

Builder copyBuilder(BuilderOptions options) => CopyBuilder(options);

Let’s modify the code of CopyBuilder as follows.

class CopyBuilder implements Builder {  
const CopyBuilder(this.options);
final BuilderOptions options;

@override
FutureOr<void> build(BuildStep buildStep) async {
AssetId inputId = buildStep.inputId;

var copyAssetId = inputId.changeExtension('.copy.txt');
var contents = await buildStep.readAsString(inputId);

contents = '''
name: ${options.config['name'] ?? 'unknown'}
---------
$contents
''';

await buildStep.writeAsString(copyAssetId, contents);
}

@override
Map<String, List<String>> get buildExtensions => {
'.txt': ['.copy.txt'],
};
}

And if you run dart run build_runner build, you can see that the name is correctly entered.

If we are in the position of developing the package and there are users using it, we can request them to provide options as follows.

Appendix 1 — Get global customization build options from user

If you look at the documentation of freezed or the Configuration file of flutter_gen, you'll find that when we distribute the Code Generator in the form of a package, we offer the feature allowing users to customize the operation of the Generator using their own options.

This can be grouped by two categories:

  1. Customizing by inserting arguments inside annotations at the code level.
  2. Inducing to set default values for the package globally in build.yaml or pubspec.yaml.

If we focus on the latter, it might be confusing as to why these two methods are different.

Defining in build.yaml is the standard method, while the latter approach provides convenience to users by directly reading the configuration file like pubspec.yaml.

The method of defining in build.yaml can be verified in YouTube tutorials. This method is convenient from the perspective of package development because you can receive and use the settings through the BuilderOptions object directly.

By looking at the internal code of flutter_gen's Builder, you can see that it reads pubspec.yaml directly through File IO and simply parses it into a Map.

analyzer Package

The analyzer is one of the most complex subjects to grasp when trying to understand Dart's code generation.

Strictly speaking, the analyzer is a package that performs static analysis on Dart code, converting it into an AST (Abstract Syntax Tree, Syntactic Model) or an Element Model (Semantic Model). Tools like dart format or dart doc internally utilize this package for their operations.

Therefore, when we define a Builder in places like build and want to analyze Dart code to perform Code Generation according to our desired logic, using the analyzer becomes indispensable.

However, it’s also true that by using the source_gen package, which we will explore later, one can achieve code generation in a more developer-friendly manner without delving deep into the somewhat verbose APIs of the analyzer package.

It’s alright if you only skim through the analyzer package section. It's not essential to delve deep into it.

After extensive searching on Google, I was able to find dart-lang/sdk/analyzer/doc/tutorial, where I could study the concepts.

If you're not familiar with topics like compilers and AST (Abstract Syntax Tree), I recommend starting by learning how static code analysis works.

Mental Model of analyzer Package

The tutorial “Performing Analysis” describes the general mental model of the analyzer.

The main point is represented as output = analyze(input, context). While one might typically expect a consistent output when a same source file is given as input, the output can actually change based on external factors, such as the existence of an analysis options file or the presence of other files.

Therefore, a crucial consideration in the analyze API is handling this context effectively to ensure consistent outputs.

This is especially important in incremental analysis environments, such as Dart’s analysis server.

If you don’t have an incremental analysis environment, there’s no need to think too deeply about the context.

The API is as follows:

The AnalysisContextCollection is a collection of paths for the files to be analyzed.

By inputting a path into the AnalysisContextCollection, you can obtain an AnalysisContext, and through this, you can get an AnalysisSession.

This session contains the context mentioned above. If inconsistent results are returned from the same session, an exception is thrown to ensure incremental analysis.

Handling this session is continued in the AST or Element API.

AST(Abstract Syntax Tree) Model

The Abstract Syntax Tree (AST) in programming is an API that deals with the AST itself, structured as a tree of the AstNode class.

All subclasses of AstNode can be found in the Docs.

Simply put, the AST represents a programming language converted into a syntactic tree form.

For example, the children of a single Dart file might include import statements, class declarations, etc. And the children of a class declaration might consist of class methods and fields.

The root of the tree is the CompilationUnit, which represents a unit for compilation.

All nodes in the AST have getters for adjacent parents and children in the tree. For instance, a BinaryExpression has a parent, leftOperand, and rightOperand.

The AST can be in a resolved or unresolved state. In its resolved state, the resolution information it carries signifies the Element and Type information combined with the AST.

Using the AnalysisSession, you can obtain the root of the AST, the CompilationUnit, and traverse the tree to get the information you need.

// don't require Resolution Information 
Future<void> processFile(AnalysisSession session, String path) async {
var result = session.getParsedUnit(path);
if (result is ParsedUnitResult) {
CompilationUnit unit = result.unit;
}
}

// With Resolution Information
Future<void> processFile(AnalysisSession session, String path) async {
var result = await session.getResolvedUnit(path);
if (result is ResolvedUnitResult) {
CompilationUnit unit = result.unit;
}
}

Traversing the tree can be done either by manually using the aforementioned getters, checking types, and implementing by hand, or by utilizing the Visitor Pattern.

There are several Visitor classes, which are subclasses that implement the AstVisitor.

For details on all the Visitors, one should refer to the documentation.

The commonly used ones are SimpleAstVisitor, RecursiveAstVisitor, and GeneralizingAstVisitor.

  • SimpleAstVisitor: Implements every visit method by doing nothing.
  • RecursiveAstVisitor: Causes every node in a structure to be visited.
  • GeneralizingAstVisitor: Makes it easy to visit general kinds of nodes, such as visiting any statement or any expression.

Typically, you can start your Visitor by subclassing one of these.

Element Model

The Type Model and its counterpart, the Element Model, are semantic representations of Dart code. What does it mean to be semantic?

It can be seen as a representation that deals more directly with the elements managed by the language itself, such as Classes, Methods, Constructors, Interfaces, etc.

It’s essential not to think of the semantic Element Model as a higher level than the syntactic AST Model just because it’s more developer-friendly.

In packages like source_gen, while you will encounter the AST, it's more common to interact with the Element.

For instance, if you look at the code that implements GeneratorForAnnotation<T> in json_serializable supported by source_gen, you'll see the generateForAnnotatedElement function.

The first argument provided to this function is an Element, which comes from the analyzer.

Using conditionals like element is EnumElement, you can easily determine the type of the Element and make use of developer-friendly APIs.

The root of the Element tree is typically the LibraryElement, which represents a library in Dart.

Since a library in Dart can have multiple parts, it can have one or more CompilationUnitElement children.

Just like with the AST, if you have an AnalysisSession, you can retrieve an Element as follows.

void analyzeSingleFile(AnalysisSession session, String path) async {
var result = await session.getUnitElement(path);
if (result is UnitElementResult) {
CompilationUnitElement element = result.element;
}
}

In the Element Model, tree traversing is not different from the AST. Just as there is ASTVisitor in AST, there’s ElementVisitor in the Element Model, and there are classes with the similar prefix.

The full list of Visitors can be found in the Docs.

Type Model

In the AST/Element Model, if you want to check the return type of a function, you can use the Type Model.

All of them are subclasses of DartType.

You can obtain this DartType in both the AST and Element Model.

In the AST, it should have Resolution Information, and you can access it through the non-null type like staticType.

In the Element Model, the usage is more straightforward, and for example, if it’s a function, properties like returnType all become DartType.

analyzer package outro

After all the explanations, the key takeaway is that by having a basic understanding of the syntax of AST, Element, and Type, you should be able to read, search, and use them effectively.

source_gen will become your friend with just this level of understanding.

If you’re using source_gen , you might not even need to access the AnalysisSession directly.

Additionally, since you’re analyzing the same code, it’s note to worth that AST and Element can be transformed into each other, representing the same information.

source_gen Package

You can see from the source_gen API docs that there isn’t much you need to know.

Our main goal was to implement the Builder interface in the build package.

source_gen provides typical Builders and Generators to make it easier to implement such builders.

Builder Types

Typically, using the part and part of statements in Dart allows you to split code within the same library into different files.

In the context of Code Generation, this concept is actively used to generate files like a.g.dart or a.xxx.dart based on an existing a.dart.

If you want to create files with the .g.dart extension, you can use SharedPartBuilder. As the name suggests, it's a PartBuilder, but multiple SharedPartBuilders can collectively store their logic in a shared file named .g.dart. This way, code generated by different builders from various packages can be shared in the same .g.dart file.

On the other hand, LibraryBuilder is a builder that simply creates a single file. You can import and use it directly in your code.

build.yaml Settings

Builder for using SharedPartBuilder, you have to config build_to: cache, and apply combining_builder.

When configuring the build.yaml for build_config support, there are several things to keep in mind:

Builders that use SharedPartBuilder should specify build_to: cache and apply the combining_builder. This ensures that generated files with .g.dart extensions are appropriately handled.

Since there’s no guarantee that .g.dart files will only be used internally, it's a good practice to create your files in a hidden folder. Let source_gen's combining_builder manage the aggregation of results into the .g.dart file.

You can find an example that applies these considerations in the source_gen documentation.

builders:
some_cool_builder:
import: "package:this_package/builder.dart"
builder_factories: ["someCoolBuilder"]
# The `partId` argument to `SharedPartBuilder` is "some_cool_builder"
build_extensions: {".dart": [".some_cool_builder.g.part"]}
auto_apply: dependents
build_to: cache
# To copy the `.g.part` content into `.g.dart` in the source tree
applies_builders: ["source_gen:combining_builder"]

The following is build.yaml of freezed package.

targets:
$default:
builders:
freezed:
enabled: true
generate_for:
exclude:
- test
- example
include:
- test/integration/*
- test/integration/**/*
source_gen|combining_builder:
options:
ignore_for_file:
- "type=lint"
builders:
freezed:
import: "package:freezed/builder.dart"
builder_factories: ["freezed"]
build_extensions: { ".dart": [".freezed.dart"] }
auto_apply: dependents
build_to: source
runs_before: ["json_serializable|json_serializable"]

freezed uses a unique extension called .freezed.dart and does not utilize SharedPartBuilder, so the rules I mentioned earlier were not applied.

Let's take a look at the ignore_for_file option in the source_gen|combining_builder options.

This option allows you to apply lint rules to the generated file.

In fact, when we examine the files generated by freezed, they typically start like this.

Let’s explore Generator by creating actual examples.

Example 3 — Generation with source_gen SharedPartBuilder

Let’s now try Code Generation using source_gen. The builder we're going to create is as follows

  • top_level_names (SharedPartBuilder) - Declare a string variable that combines the names of all global variables existing in the file.

1. Addsource_gen, analyzer packages

dependencies:  
analyzer: '>=5.2.0 <7.0.0'
source_gen: ^1.4.0

2. Set upbuild.yaml

targets:  
$default:
builders:
code_generation|topLevelNamesBuilder:
generate_for:
- lib/example/*.dart
enabled: True

builders:
# The next builder is `SharedPartBuilder` instances.
# Notice they each have # `build_extensions` set to a `NAME.g.part` file.
# NAME corresponds to the second argument to the SharedPartBuilder ctor.
# `.g.part` is the extension expected of SharedPartBuilder.
# `build_to: cache` - the output is not put directly into the project
# `applies_builders` - uses the `combining_builder` from `source_gen` to
# combine the parts from each builder into one part file.
topLevelNamesBuilder:
import: 'package:code_generation/code_generation.dart'
builder_factories: [ 'topLevelNamesBuilder' ]
build_extensions: {".dart": ["topLevelNames.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]

Inside the example project, we will execute the example from the lib/example directory, so make sure to specify generate_for as indicated above.

Pay attention to the format of build_extensions (see comments).

3. Builder Exporter

// code_generation.dart

import 'package:build/build.dart';
import 'package:code_generation/top_level_names_generator.dart';
import 'package:source_gen/source_gen.dart';

Builder topLevelNamesBuilder(BuilderOptions options) => SharedPartBuilder(
[TopLevelNamesGenerator()],
'topLevelNames',
);

We have now exposed the topLevelNamesBuilder builder to build, and here we return a SharedPartBuilder with Generator as the first argument and an ID (partId) as the second argument.

It's important to note that the partId you pass here must match what you've specified in build.yaml under build_extensions.

4. Generator

A Generator’s role is simply to receive a LibraryReader and return generated source code.

You only need to inherit from Generator and implement the generate method.

// top_level_names_generator.dart

import 'dart:async';

import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';

class TopLevelNamesGenerator extends Generator {
@override
FutureOr<String> generate(LibraryReader library, BuildStep buildStep) {
var joinedVariableName =
library.allElements.whereType<TopLevelVariableElement>().map((e) => e.name).join(',');
var joinedClassName =
library.allElements.whereType<ClassElement>().map((e) => e.name).join(',');

return '''
// Source library: ${library.element.source.uri}
const topLevelVariableNames = '$joinedVariableName';
const classNames = '$joinedClassName';
''';
}
}

The second argument of generate, buildStep, is the same buildStep that was being passed in the Builder. The first argument, LibraryReader, provides access to the LibraryElement through its element property, and it offers various utilities for reading the code within the library.

5. Create Example file

// example/example.dart

part 'example.g.dart';

final name = 'John Doe';
final age = 95;

class Dog {
const Dog(this.name, this.age);
final String name;
final int age;
}

When using PartBuilder, it's important to declare part ... in advance. You can see a similar instruction in packages like freezed and json_serializable.

Now, when you execute dart run build_runner build, you can confirm that our code is generated correctly.

// GENERATED CODE - DO NOT MODIFY BY HAND  

part of 'example.dart';

// **************************************************************************
// TopLevelNamesGenerator
// **************************************************************************

// Source library: package:code_generation/example/example.dart
const topLevelVariableNames = 'name,age';
const classNames = 'Dog';

Example 4 — Usage of Multiple Generator, Builder Types

We will create two SharedPartBuilder instances to confirm that two files are correctly merged into .g.dart.

Additionally, we'll create a PartBuilder, resulting in a total of three builders.

This is based on examples from the source_gen package, and here's what each builder will do:

  1. top_level_names (SharedPartBuilder): Declare a string variable that combines the names of all global variables in the file.
  2. multiplier (SharedPartBuilder): Declare variables for global int variables in the file that have the @Multiplier annotation, multiplying them by the value specified in the annotation.
  3. add (PartBuilder): Declare variables in a separate .add.dart file for global int variables in the file with the @Add annotation, adding the value specified in the annotation.

1. build.yaml

targets:  
$default:
builders:
code_generation|topLevelNamesBuilder:
generate_for:
- lib/example/*.dart
enabled: True
code_generation|multiplierBuilder:
generate_for:
- lib/example/*.dart
enabled: True
code_generation|addBuilder:
generate_for:
- lib/example/*.dart
enabled: True



builders:
addBuilder:
import: 'package:code_generation/code_generation.dart'
builder_factories: [ 'addBuilder' ]
build_extensions: { ".dart": [ ".add.dart" ] }
auto_apply: dependents
build_to: source


# The next two builders are `SharedPartBuilder` instances.
# Notice they each have # `build_extensions` set to a `NAME.g.part` file. # NAME corresponds to the second argument to the SharedPartBuilder ctor. # `.g.part` is the extension expected of SharedPartBuilder. # `build_to: cache` - the output is not put directly into the project # `applies_builders` - uses the `combining_builder` from `source_gen` to # combine the parts from each builder into one part file.
topLevelNamesBuilder:
import: 'package:code_generation/code_generation.dart'
builder_factories: [ 'topLevelNamesBuilder' ]
build_extensions: {".dart": ["topLevelNames.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]
multiplierBuilder:
import: 'package:code_generation/code_generation.dart'
builder_factories: [ 'multiplierBuilder' ]
build_extensions: {".dart": ["multiplier.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]

The reason for setting targets in the build.yaml file is that we didn't separate the example project in this project.

Depending on the auto_apply property, Code Generation packages provided as dependencies may not require explicit target declarations.

In fact, in the source_gen example, there are no targets declared.

2. Declare annotation

// annotations.dart

class Multiplier {
final num value;

const Multiplier(this.value);
}

class Add {
final num value;

const Add(this.value);
}

3. Export Builder

import 'package:build/build.dart';  
import 'package:code_generation/add_generator.dart';
import 'package:code_generation/multiplier_generator.dart';
import 'package:code_generation/top_level_names_generator.dart';
import 'package:source_gen/source_gen.dart';

Builder topLevelNamesBuilder(BuilderOptions options) => SharedPartBuilder(
[TopLevelNamesGenerator()],
'topLevelNames',
);

Builder multiplierBuilder(BuilderOptions options) => SharedPartBuilder(
[MultiplierGenerator()],
'multiplier',
);

Builder addBuilder(BuilderOptions options) => PartBuilder(
[AddGenerator()],
'.add.dart',
options: options,
);

Notice that we use a new builder PartBuilder.

4. Generator

Let’s move on and just take a look at the MultiplierGenerator to introduce GeneratorForAnnotation<T>

// multiplier_generator.dart

import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';

import 'annotations.dart';

class MultiplierGenerator extends GeneratorForAnnotation<Multiplier> {
@override
String generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
if (element is! TopLevelVariableElement || !element.type.isDartCoreInt) {
throw InvalidGenerationSourceError(
'@Multiplier can only be applied on top level integer variable. Failing element: ${element.name}',
element: element,
);
}

final numValue = annotation.read('value').literalValue as num;

return 'num ${element.name}Multiplied() => ${element.name} * $numValue;';
}
}

In generateForAnnotatedElement, an Element object is passed in, and you can read the annotation's values as well.

Since it's no longer just library-level elements coming in here, if an unexpected element is encountered, you should throw an InvalidGenerationSourceError.

In the provided code, element.type is the DartType object from the Type Model, as explained earlier. In practice, if we were to attach @Multiplier to a String type, it might look like this.

The following error occurred during the build process.

Conclusion

We explored how Code Generation works in Dart and explored the roles and usage of various packages like build, build_config, analyzer, and source_gen.

One thing I noticed when I initially learned Dart is that the language relies heavily on Static Code Generation. As I continued to use it, I understood the advantages of this approach and why it has become popular among modern languages.

In fact, in Swift 5.9 and beyond, even Macros are actively discussed, and Apple is providing developers with the means to define their own Macros, as presented at WWDC 2023. This suggests that Code Generation could become a trending(?) practice in the development world.

Where to go?

If you’re planning to create your own Code Generation package, it’s highly recommended to follow the conventions set by well-established packages like freezed or json_serializable. By adhering to these conventions, you can ensure that your package is familiar and compatible with existing tools and workflows in the Dart ecosystem.

Additionally, it’s a good idea to explore more complex use cases and write tests using packages like build_test to ensure your package functions correctly. This will not only result in a robust package but also provide you with a deeper understanding of Code Generation and how it can be applied effectively in your projects.

--

--