← Back to posts Cover image for Demystifying Dart's Reflection: When to Use Code Generation for Powerful Flutter Features

Demystifying Dart's Reflection: When to Use Code Generation for Powerful Flutter Features

· 6 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

As Flutter developers, we often need to build dynamic, flexible systems. Maybe you’re creating a serialization library, a dependency injection framework, or a testing utility that needs to inspect types at runtime. If you’re coming from languages like C# or Java, your first instinct might be to reach for reflection—only to find Dart’s capabilities feel limited in Flutter. Let’s explore why this is, and more importantly, how to work powerfully within these constraints.

The Reflection Dilemma in Flutter

Reflection, in essence, is the ability of a program to examine and modify its own structure and behavior at runtime. It’s incredibly useful for building generic tools that can work with types they don’t know about in advance.

So why doesn’t Flutter embrace it fully? The answer comes down to one crucial optimization: tree shaking.

Tree shaking is the process of removing unused code from your final app bundle. For mobile apps where every kilobyte counts, this is essential. Full runtime reflection would require keeping all your code’s metadata and structure available at runtime, making it impossible for the compiler to determine what’s actually used. The result? Significantly larger app sizes.

Understanding dart:mirrors and Its Limitations

Dart does have a reflection API: the dart:mirrors library. You can use it in pure Dart server-side or command-line applications. Here’s what it looks like:

import 'dart:mirrors';

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

void main() {
  var user = User('Alice', 30);
  var mirror = reflect(user);
  
  // Inspect the type
  print('Type: ${mirror.type.simpleName}');
  
  // List all instance fields
  mirror.type.instanceMembers.forEach((key, value) {
    print('Member: $key');
  });
}

This works great in Dart VM environments. However, try to import dart:mirrors in a Flutter app, and you’ll encounter a compile-time error. Flutter deliberately disables it to preserve tree shaking.

The Compile-Time Alternative: Code Generation

The solution that Flutter embraces is compile-time code generation. Instead of inspecting types at runtime, we generate the necessary code during development. This gives us the dynamic capabilities we need while maintaining tree shaking, since all generated code is explicit and visible to the compiler.

The primary tool for this is the build_runner system with packages like json_serializable, freezed, or injectable. Let’s see how this works in practice.

Practical Example: Building a Simple Serialization Framework

Let’s say we want to create a simple serialization system that converts objects to JSON without manual boilerplate. We’ll use annotations and code generation.

First, create our annotation class:

// annotation.dart
class JsonSerializable {
  const JsonSerializable();
}

Now, let’s create a class we want to serialize:

// user.dart
import 'annotation.dart';

@JsonSerializable()
class User {
  final String name;
  final int age;
  final String email;
  
  User(this.name, this.age, this.email);
  
  Map<String, dynamic> toJson() {
    // We'll generate this method!
    throw UnimplementedError('toJson will be generated');
  }
  
  factory User.fromJson(Map<String, dynamic> json) {
    // We'll generate this factory too!
    throw UnimplementedError('fromJson will be generated');
  }
}

Next, we create our code generator. This is where the magic happens:

// generator.dart
import 'dart:async';
import 'package:build/src/builder/build_step.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:source_gen/source_gen.dart';
import 'package:annotation/annotation.dart';

class JsonSerializableGenerator extends GeneratorForAnnotation<JsonSerializable> {
  @override
  FutureOr<String> generateForAnnotatedElement(
    Element element,
    ConstantReader annotation,
    BuildStep buildStep,
  ) async {
    // Ensure we're only processing classes
    if (element is! ClassElement) {
      throw InvalidGenerationSourceError(
        'Only classes can be annotated with @JsonSerializable',
        element: element,
      );
    }
    
    final className = element.name;
    final fields = element.fields.where((f) => f.isFinal && !f.isStatic);
    
    // Generate toJson method
    final toJsonCode = StringBuffer();
    toJsonCode.writeln('Map<String, dynamic> toJson() {');
    toJsonCode.writeln('  return {');
    
    for (var field in fields) {
      final fieldName = field.name;
      toJsonCode.writeln("    '$fieldName': $fieldName,");
    }
    
    toJsonCode.writeln('  };');
    toJsonCode.writeln('}');
    
    // Generate fromJson factory
    final fromJsonCode = StringBuffer();
    fromJsonCode.writeln('factory $className.fromJson(Map<String, dynamic> json) {');
    fromJsonCode.writeln('  return $className(');
    
    for (var field in fields) {
      final fieldName = field.name;
      final fieldType = field.type.getDisplayString();
      
      // Simple type conversion - in reality you'd handle more types
      fromJsonCode.writeln("    $fieldName: json['$fieldName'] as $fieldType,");
    }
    
    fromJsonCode.writeln('  );');
    fromJsonCode.writeln('}');
    
    return '''
extension _\$${className}Json on $className {
  $toJsonCode
  
  $fromJsonCode
}
''';
  }
}

To use this generator, you’d configure it in your build.yaml file and run flutter pub run build_runner build. The generator would create a new file with the serialization code:

// user.g.dart
extension _$UserJson on User {
  Map<String, dynamic> toJson() {
    return {
      'name': name,
      'age': age,
      'email': email,
    };
  }
  
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      name: json['name'] as String,
      age: json['age'] as int,
      email: json['email'] as String,
    );
  }
}

Common Patterns and Best Practices

  1. Use Existing Packages First: Before building your own code generator, check if packages like json_serializable, freezed, or injectable already solve your problem.

  2. Keep Generators Focused: Each generator should do one thing well. If you need multiple transformations, create separate generators.

  3. Handle Edge Cases: Your generator should gracefully handle:

    • Nullable types
    • Nested objects
    • Collections (List, Map, Set)
    • Inheritance and mixins
  4. Write Tests for Your Generator: Test your generator with various class structures to ensure it produces correct code.

  5. Consider Performance: While code generation happens at compile time, complex generators can slow down your build process. Profile and optimize if needed.

When to Build Your Own Generator

You might consider building a custom code generator when:

  • You’re creating an internal framework or library
  • Existing solutions don’t fit your specific use case
  • You need consistent code patterns across many classes
  • You’re building tools for testing, mocking, or data generation

The Trade-Off: Development Time vs. Runtime Performance

The code generation approach does require some setup and understanding of the build system. However, the benefits are substantial:

  • Tree shaking preserved: Your final app stays small
  • Runtime performance: Generated code is as fast as hand-written code
  • Type safety: All generated code is checked by the Dart analyzer
  • No runtime surprises: Everything is known at compile time

Conclusion

While Dart’s restricted reflection in Flutter might seem limiting initially, it’s a thoughtful trade-off that enables the small app sizes and fast performance that make Flutter great. By embracing code generation, you can build equally powerful dynamic tools while maintaining all of Flutter’s optimization benefits.

The key mindset shift is moving from “inspect at runtime” to “generate at compile time.” Once you’re comfortable with this pattern, you’ll find you can build almost any reflective tool you need—with the added bonus of better performance and smaller app sizes.

Start by exploring existing code generation packages in your projects, then consider building your own generators for specialized needs. You might find that this compile-time approach is actually more predictable and maintainable than runtime reflection ever was.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Demystifying Dart's Reflection: When to Use Code Generation for Powerful Flutter Features

Demystifying Dart's Reflection: When to Use Code Generation for Powerful Flutter Features

Dart's lack of full runtime reflection in Flutter often frustrates developers used to languages like C#, limiting dynamic tool building. This post will clarify why Flutter restricts reflection (tree-shaking benefits), explain the `dart:mirrors` library's role, and most importantly, provide practical strategies for achieving similar powerful capabilities through compile-time code generation and annotations, with real-world examples.

Cover image for Fixing Flutter ANR Issues: Strategies for Unblocking the Main Thread

Fixing Flutter ANR Issues: Strategies for Unblocking the Main Thread

App Not Responding (ANR) errors plague many Flutter apps, often misdiagnosed as general slowness. This post will delve into identifying and resolving ANRs by focusing on common causes of main thread blocks, providing practical tools and techniques for ensuring a smooth, responsive user experience.

Cover image for Mastering CI/CD for Flutter: A Practical Guide to Fastlane and GitHub Actions

Mastering CI/CD for Flutter: A Practical Guide to Fastlane and GitHub Actions

Implementing robust Continuous Integration and Continuous Deployment (CI/CD) is essential for shipping Flutter apps efficiently, yet many developers struggle with setting up reliable pipelines for Android and iOS. This post will provide a practical guide to leveraging Fastlane and GitHub Actions to automate builds, testing, and deployments, addressing common challenges and sharing best practices for a streamlined release workflow.