Enhancing Your Flutter Project with Typesafe Packages
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:
Typesafe Method Channels: pigeon
Typesafe GraphQL: ferry
Typesafe Routing: go_router_builder
Typesafe Assets: flutter_gen
Typesafe Environment Variables: envied
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.
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');
}
}
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.
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);
}
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,chopper
and 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!
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.