devShakib

Bridging Swift and Kotlin With Pigeon: Type-Safe Flutter Plugins Done Right

Pigeon generates type safe Flutter platform channels across Dart, Swift, and Kotlin — killing MethodChannel runtime crashes. Learn setup, HostApi vs FlutterApi, and facade architecture.

Every Flutter developer eventually hits the moment where the framework can't reach far enough — a specific Bluetooth characteristic, a native ML runtime, a camera capability the plugins on pub.dev don't expose. So you reach for a MethodChannel, and within a week you're debugging a crash because someone passed an int where the Swift side did as! String. I've shipped enough native integrations at this point to have a firm opinion: hand-written MethodChannels are a liability, and Pigeon is the default I reach for when I need type-safe communication between Dart and native code.

This post walks through why untyped platform channels rot, how Pigeon's code generation moves an entire class of bugs from runtime to compile time, and — the part most tutorials skip — how to architect the Dart/native boundary so the generated code doesn't leak into your app. If you've ever shipped a Flutter plugin that crashed on one platform but not the other, this is the pattern that fixes it.

Why stringly-typed MethodChannels rot

A MethodChannel is, fundamentally, a String method name and a bag of dynamically-typed arguments. Nothing checks that the Dart side and the native side agree. Consider what a "simple" call actually asks of you:

You are now maintaining the same contract in three places, in three languages, with zero compiler help. Rename a parameter and nothing breaks until runtime — on a user's device, in a country you don't have a test phone for. Add a field to the return map and you'll find out you forgot the iOS side when a QA build silently returns null. The failure mode is always the same: a PlatformException or a force-unwrap crash that only reproduces on one platform.

Here's what that hand-written boilerplate actually looks like on the native side, so the cost is concrete:

// The untyped Kotlin side of a MethodChannel — every arg is a cast waiting to failchannel.setMethodCallHandler { call, result ->  when (call.method) {    "getBatteryLevel" -> {      val unit = call.argument<String>("unit") // null if Dart sent the wrong key      val level = getBatteryLevel()      result.success(mapOf("level" to level, "unit" to unit))    }    else -> result.notImplemented()  }}

Nothing in that code knows what Dart intends to send. The "unit" key is a magic string that must match a magic string in Dart and in Swift. Serialization is the other quiet tax. Passing a structured object means manually flattening it to a Map<String, dynamic> on one side and rebuilding it on the other. Every nested model is a hand-written encoder and decoder per platform. It's tedious, and tedium is where bugs live.

There's also a testing blind spot. Because the contract only exists as convention, your unit tests can pass on both sides while the integration is broken — Dart happily mocks the channel, native happily mocks its inputs, and neither notices they disagree about the shape of BatteryStatus. The mismatch only surfaces on a real device.

Pigeon inverts the problem

Pigeon is a code generator maintained by the Flutter team. You define your interface once, in Dart, as an abstract class. It then generates the Dart calling code, the Swift/Objective-C protocol, and the Kotlin/Java interface — all type-safe, all sharing one serialization scheme. The channel names, the argument packing, the null handling: generated. You implement the native side against a real protocol with a real compiler behind it.

Add it as a dev dependency first, since it only runs at build time and ships nothing to your users:

dev_dependencies:  pigeon: ^22.0.0   # pin to whatever the latest stable is when you read this

The definition lives in a file you never ship — say pigeons/messages.dart:

import 'package:pigeon/pigeon.dart';@ConfigurePigeon(PigeonOptions(  dartOut: 'lib/src/messages.g.dart',  swiftOut: 'ios/Runner/Messages.g.swift',  kotlinOut: 'android/.../Messages.g.kt',  kotlinOptions: KotlinOptions(package: 'com.devshakib.battery'),))class BatteryStatus {  late int level;  late bool isCharging;  late ChargingSource source;}enum ChargingSource { none, ac, usb, wireless }@HostApi()abstract class BatteryHostApi {  BatteryStatus getStatus();  @async  bool startMonitoring(int intervalMs);}

Two things here earn their keep. First, BatteryStatus is a real class on all three sides — no maps, no string keys, and the ChargingSource enum crosses the boundary intact. Second, the direction is explicit. @HostApi() means Dart calls native (the native platform is the "host" hosting the Flutter engine). When you need the reverse — native pushing events up to Dart, like a battery-level change from an Android broadcast receiver or an iOS NSNotification — you annotate a class with @FlutterApi() and native code calls into generated Dart. That distinction is the single most important structural decision in a plugin, and Pigeon makes you name it up front instead of improvising an EventChannel later.

HostApi vs FlutterApi: getting the direction right

This trips people up, so it's worth being precise:

Most non-trivial plugins need both: a HostApi for the commands and a FlutterApi for the event stream back. Deciding this at design time, in one Dart file, is far cheaper than discovering three weeks in that your MethodChannel-only design has no clean way to push events.

Generating the code

Generation is a one-liner you wire into your workflow:

dart run pigeon --input pigeons/messages.dart

I keep this in a Makefile or a melos script so nobody has to remember the flags, and I re-run it on every contract change. Whether you commit the generated files or gitignore them is a team preference — I lean toward committing them so CI and code review can see the actual generated surface, but never hand-edit them either way.

Now the Swift side implements a generated protocol. If you rename level to percentage in the Dart definition and regenerate, the Swift and Kotlin code stop compiling until you fix them. That's the whole point — the contract drift that used to surface as a runtime crash is now a build error you can't merge past.

class BatteryHandler: BatteryHostApi {  func getStatus() throws -> BatteryStatus {    let device = UIDevice.current    device.isBatteryMonitoringEnabled = true    return BatteryStatus(      level: Int(device.batteryLevel * 100),      isCharging: device.batteryState == .charging,      source: device.batteryState == .charging ? .ac : .none    )  }  // startMonitoring generated as async with a completion handler}

And you register it once in your plugin's entry point, wiring the generated setup function to your handler:

// In your FlutterPlugin's register(with:) — one line connects the whole APIBatteryHostApiSetup.setUp(  binaryMessenger: registrar.messenger(),  api: BatteryHandler())

The Kotlin side is symmetrical: implement the generated interface, call the generated setUp, done. No when block, no string matching, no manual argument casting.

Structuring the Dart/native boundary

Generated code gets you type safety, but it doesn't get you a good architecture. The mistake I see most is letting messages.g.dart leak into the app. Never expose the generated API as your public surface. It's an implementation detail, and its shape is dictated by what Pigeon can generate — not by what your callers want.

The structure I use in every plugin:

Here's roughly what that facade looks like — the seam between "what Pigeon generated" and "what my app wants to call":

class BatteryRepository {  BatteryRepository() : _api = BatteryHostApi();  final BatteryHostApi _api;  final _levelController = StreamController<int>.broadcast();  Stream<int> get levelChanges => _levelController.stream;  Future<BatteryStatus> current() async {    try {      return await _api.getStatus();    } on PlatformException catch (e) {      // Translate the raw platform error into a domain error once, here      throw BatteryUnavailableException(e.message);    }  }}

That facade layer is where you also draw the error boundary. Pigeon surfaces native throws as PlatformException on the Dart side. Catch them there and rethrow domain errors — BatteryUnavailableException — so callers never pattern-match on error-code strings. Do it once, in one place, instead of at every call site. Your app code stays clean and testable, and you can mock the facade in widget tests instead of wrestling with the platform channel binary messenger.

Common gotchas worth knowing up front

A few things I've been bitten by that the docs mention only in passing:

When Pigeon is the wrong tool

One practical caveat worth knowing: Pigeon deliberately covers the request/response and event-push cases well, but it is not a general RPC framework. For high-frequency binary streams — raw camera frames, audio buffers, real-time sensor firehoses — you still want the platform's native surfaces (a Texture widget for frames, a low-level EventChannel, or FlutterStandardTypedData for zero-copy byte transfer) rather than routing every frame through generated messages. The serialization overhead that's invisible on a once-per-second battery poll becomes real cost at 60 frames per second.

The rule of thumb I use: use Pigeon for the control plane; use the specialized channel for the firehose. Commands, configuration, and discrete events go through Pigeon. Continuous binary data goes through a texture or a raw channel. Most plugins are 95% control plane, which is exactly where Pigeon shines.

Key takeaways

The value of Pigeon isn't that it saves you a few lines of serialization boilerplate — it's that it moves an entire class of bugs from runtime to compile time. A renamed field, a mismatched type, a forgotten platform: all of these become build failures instead of production crashes on a device you'll never hold. Define the contract once in Dart, wrap the generated code in a facade your app actually wants to call, and be explicit about direction with @HostApi versus @FlutterApi. Do that, and the native boundary stops being the scary part of your codebase and becomes just another typed interface.