Enhancing Your Flutter Project with Typesafe Packages

Dinko MarinacDinko Marinac
8 min read

Introduction

Flutter's popularity has surged thanks to its ability to build beautiful and performant cross-platform applications. Large projects with many teams and developers often face a lot errors on a smaller or a larger scale due to the size of the project.

One of those problems is type safety.

What is typesafe code?

Remember all those times you added a new asset and used it in a widget. You run the app, but then it crashes because Flutter couldn’t find the asset? You probably forgot to add it to the pubspec.yaml.

I’ll give you another example.

You want to call methods from native code. Flutter provides Method Channels for this. You have to make sure that you call the correct method, with the correct parameters, otherwise, you’ll get an error like this:

Unhandled Exception: MissingPluginException(No implementation found for method getBatteryLevel on channel samples.flutter.dev/battery)

This is what typesafe code prevents from happening. Typesafe means that variables are statically checked for appropriate assignment at compile time.

As projects grow, ensuring type safety becomes crucial to maintaining code quality and preventing runtime errors.

This blog will explore how to make your Flutter project more typesafe using various packages:

Let's dive into each of these and see how they can take your Flutter project from error-prone to error-free.

Typesafe Method Channels with Pigeon

Method channels in Flutter facilitate communication between the Dart and native sides of an app (iOS and Android). Traditionally, method channels use dynamic typing, which can lead to runtime errors if the method names or argument types do not match.

pigeon eliminates these issues by generating type-safe code for method channels. It can also generate code for accessing a Dart method from native code, which can come in handy.

Non-Typesafe Code

Typically you would write something like this in your Flutter project:

import 'package:flutter/services.dart';

class FlutterBridge {
  static const MethodChannel _channel = MethodChannel('com.example.app/channel');

  Future<String?> getMessage() async {
    final String? message = await _channel.invokeMethod('getMessage');
    return message;
  }

  Future<void> sendMessage(String message) async {
    await _channel.invokeMethod('sendMessage', {'text': message});
  }
}

And then write appropriate platform implementations:

  • For Android (MainActivity.kt ):
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.example.app/channel"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            when (call.method) {
                "getMessage" -> {
                    val message = getMessage()
                    result.success(message)
                }
                "sendMessage" -> {
                    val text = call.argument<String>("text")
                    sendMessage(text)
                    result.success(null)
                }
                else -> {
                    result.notImplemented()
                }
            }
        }
    }

    private fun getMessage(): String {
        return "Hello from Android!"
    }

    private fun sendMessage(message: String?) {
        // Handle the message
    }
}
  • For iOS (AppDelegate.swift):
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)

    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let channel = FlutterMethodChannel(name: "com.example.app/channel",
                                      binaryMessenger: controller.binaryMessenger)

    channel.setMethodCallHandler({
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
      if call.method == "getMessage" {
        result(self.getMessage())
      } else if call.method == "sendMessage" {
        if let args = call.arguments as? [String: Any],
           let text = args["text"] as? String {
          self.sendMessage(message: text)
        }
        result(nil)
      } else {
        result(FlutterMethodNotImplemented)
      }
    })

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  private func getMessage() -> String {
    return "Hello from iOS!"
  }

  private func sendMessage(message: String) {
    // Handle the message
  }
}

Typesafe Code with Pigeon

First, add pigeon to your dev_dependencies in pubspec.yaml:

dev_dependencies:
  pigeon: ^19.0.1

Create a Dart file (e.g., pigeons.dart) to define the interface:

import 'package:pigeon/pigeon.dart';

class Message {
  String? text;
}

@HostApi()
abstract class Api {
  Message getMessage();
  void sendMessage(Message message);
}

Run the Pigeon tool to generate the necessary code:

flutter pub run pigeon --input pigeons.dart

This generates platform-specific code ensuring that method calls are type-safe, reducing the risk of mismatches and errors.

Typesafe GraphQL with Ferry

If you are using GraphQL, ferry provides a fully type-safe client by leveraging code generation. This ensures that queries, mutations, and subscriptions conform to the GraphQL schema, catching errors at compile time rather than at runtime. It also comes with a side package ferry_hive_store if you want to have offline persistence using Hive.

Non-Typesafe Code

import 'package:graphql_flutter/graphql_flutter.dart';

class GraphQLService {
  final GraphQLClient _client;

  GraphQLService(this._client);

  Future<void> fetchData() async {
    const String query = '''
      query GetItems {
        items {
          id
          name
        }
      }
    ''';

    final QueryResult result = await _client.query(QueryOptions(document: gql(query)));
    if (result.hasException) {
      print(result.exception.toString());
      return;
    }

    final items = result.data?['items'];
    print(items);
  }
}

Typesafe Code with Ferry

Add ferry and its code generation dependencies:

dependencies:
  ferry: ^0.16.0+1
  gql_http_link: ^1.0.1+1

dev_dependencies:
  ferry_generator: ^0.10.0
  build_runner: ^2.4.10

Move your GraphQL query into a .graphql file:

query GetItems {
        items {
          id
          name
        }
      }

Generate the necessary types by running:

flutter pub run build_runner build

Use your queries in a typesafe manner:

import 'package:ferry/ferry.dart';
import 'package:ferry_hive_store/ferry_hive_store.dart';
import 'package:ferry_http/ferry_http.dart';

final query = GFetchUserReq((b) => b..vars.id = 1);

final client = FerryClient(
  link: HttpLink('https://api.example.com/graphql'),
  cache: HiveStore(),
);

final response = await client.request(query).first;

if (response.hasErrors) {
  // Handle errors
} else {
  final user = response.data!.user;
  print('User: ${user.name}, Email: ${user.email}');
}

Type-Safe REST API with Retrofit and Chopper

Both Retrofit and Chopper and type safe REST API generators, but they cover different libraries. Chopper is based on http package, while Retrofit is based on dio package. Both are inspired by Retrofit for Android.

Non-Type-Safe Code

Directly using the http and dio requires manual handling of JSON parsing and lacks compile-time verification of API definitions, including path and query parameters.

  1. http
import 'dart:convert';
import 'package:http/http.dart' as http;

Future<MyModel> fetchPost(int id) async {
  final response = await http.get(Uri.parse('https://api.example.com/posts/$id'));
  if (response.statusCode == 200) {
    return MyModel.fromJson(jsonDecode(response.body));
  } else {
    throw Exception('Failed to load post');
  }
}
  1. dio
import 'package:dio/dio.dart';

Future<MyModel> fetchPost(int id) async {
  try {
    final response = await Dio().get('https://api.example.com/posts/$id');
    return MyModel.fromJson(response.data);
  } catch (e) {
    throw Exception('Failed to load post');
  }
}

Typesafe Code with Chopper and Retrofit

Both Chopper and Retrofit use build_runner to generate the underlying network logic by wrapping around http and dio. By using annotations you can focus on defining API contracts and business logic without sacrificing type safety.

  1. chopper
import 'package:chopper/chopper.dart';

part 'api_service.chopper.dart';

@ChopperApi(baseUrl: '/posts')
abstract class ApiService extends ChopperService {
  @Get(path: '/{id}')
  Future<Response<MyModel>> getPostById(@Path() int id);
}
  1. retrofit
import 'package:retrofit/retrofit.dart';

part 'api_service.g.dart';

@RestApi(baseUrl: "https://api.example.com")
abstract class ApiService {
  factory ApiService(Dio dio, {String baseUrl}) = _ApiService;

  @GET("/posts/{id}")
  Future<MyModel> getPostById(@Path("id") int id);
}

Now you can call your APIs like any other method in the app.

Typesafe Routing with GoRouterBuilder

Routing is a critical aspect of any Flutter app. Most used navigator packages are go_router. auto_route. AutoRoute provides typesafe and generated routes out of the box, while GoRouter relies on go_router_builder package.

Non-Typesafe Code

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

final GoRouter _router = GoRouter(
  routes: <GoRoute>[
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) => HomeScreen(),
    ),
    GoRoute(
      path: '/details',
      builder: (BuildContext context, GoRouterState state) {
        final String id = state.queryParams['id'] ?? '';
        return DetailsScreen(id: id);
      },
    ),
  ],
);

Typesafe code with GoRouterBuilder

go_router_builder integrates with the go_router package to provide type-safe routes.

Add dependencies:

dependencies:
  go_router: ^14.1.4

dev_dependencies:
  go_router_builder: ^2.7.0

Define your routes in a Dart file:

import 'package:go_router/go_router.dart';

part 'app_routes.g.dart';

@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute {}

@TypedGoRoute<DetailsRoute>(path: '/details')
class DetailsRoute {
  final String id;

  DetailsRoute({required this.id});
}

Run the generator:

flutter pub run build_runner build

This will generate all the routes for you. The code generator combines all top-level routes into a single list called $appRoutes which you need to use to initialize the GoRouter instance:

final _router = GoRouter(routes: $appRoutes);

You can now navigate and pass parameters without worrying about something going wrong:

void _tap() => DetailsRoute(id: 'p1').go(context);

Typesafe Assets with FlutterGen

Managing assets in Flutter can become cumbersome as your project scales. flutter_gen helps manage this by generating type-safe code for your assets. The package also provides utils for Lottie, Rive, and SVG files.

Always configure your assets in pubspec.yaml:

flutter:
  assets:
    - assets/images/

Non-Typesafe Code

Image.asset('assets/images/logo.png')

Typesafe Code with FlutterGen

Add flutter_gen:

dev_dependencies:
  flutter_gen: ^5.5.0+1

Run the generator:

flutter pub run build_runner build

Access your assets safely:

import 'package:my_app/gen/assets.gen.dart';

Image.asset(Assets.images.logo.path);

Typesafe Environment Variables with Envied

Managing environment variables securely and type-safely can be challenging. envied makes it easy. Envied can also obfuscate your API keys to make them more secure, but beware, it’s just a xor of two strings and anybody who wants to steal your API keys will steal them.

Adding envied:

dependencies:
  envied: ^0.5.4+1

dev_dependencies:
  build_runner: ^2.4.10

Create an .env file and a Dart class for the environment variables:

API_KEY=your_api_key
import 'package:envied/envied.dart';

part 'env.g.dart';

@Envied(path: '.env')
abstract class Env {
  @EnviedField(varName: 'API_KEY')
  static const String apiKey = _Env.apiKey;
}

Run the generator:

flutter pub run build_runner build

Access your environment variables safely:

final apiKey = Env.apiKey;

Conclusion

By integrating these packages into your Flutter project, you can significantly enhance type safety, making your code more robust and maintainable. Leveraging pigeon for method channels, ferry for GraphQL,chopperand retrofit for REST APIs, go_router_builder for routing, flutter_gen for assets, and envied for environment variables, you can catch errors at compile time, ensuring a more reliable and error-free codebase.

If you have found this useful, make sure to like and follow for more content like this. To know when the new articles are coming out, follow me on Twitter or LinkedIn.

Until next time, happy coding!

0
Subscribe to my newsletter

Read articles from Dinko Marinac directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dinko Marinac
Dinko Marinac

Mobile app developer and consultant. CEO @ MOBILAPP Solutions. Passionate about the Dart & Flutter ecosystem.