Type-safe native calls with Pigeon
Platform channels in Flutter let you call native code, but manually parsing messages in Kotlin and Swift is error-prone and tedious. Pigeon solves this by generating type-safe boilerplate code from a single Dart API definition, eliminating entire categories of runtime type errors and cutting your native integration time in half.
Why manual platform channels fail at scale
I've debugged enough crashes from mismatched message types across channels to know that manual platform channel code doesn't scale. When your Dart code sends a list of strings but your Kotlin handler expects a map, you get a runtime exception. When you rename a field in your API contract, you have to remember to update the parsing logic in three places—and usually you forget one.
The traditional approach looks like this:
// Dart side
final result = await platform.invokeMethod('getUserData', {'id': 123});
Then in Kotlin you're doing string matching and type casting:
// Kotlin side (pseudocode)
when (call.method) {
"getUserData" -> {
val id = call.argument<Int>("id") ?: return
// ... now handle nullable id
}
}
This works, but it's boilerplate that duplicates your contract in multiple languages. Pigeon generates all of this from a single Dart definition, giving you compile-time safety and type checking across the entire stack.
Define the API in Dart
Pigeon starts with a .dart file that acts as your contract. You define your native methods, request/response types, and error codes in pure Dart.
Here's a real example I built for a trading platform that needed to fetch real-time market data from native code:
import 'package:pigeon/pigeon.dart';
class MarketDataRequest {
final String symbol;
final int timeframeSeconds;
MarketDataRequest({required this.symbol, required this.timeframeSeconds});
}
class PricePoint {
final double open;
final double high;
final double low;
final double close;
final int timestamp;
PricePoint({
required this.open,
required this.high,
required this.low,
required this.close,
required this.timestamp,
});
}
class MarketDataResponse {
final String symbol;
final List<PricePoint> points;
final bool isLive;
MarketDataResponse({
required this.symbol,
required this.points,
required this.isLive,
});
}
@HostApi()
abstract class MarketApi {
Future<MarketDataResponse> fetchPrices(MarketDataRequest request);
Future<void> startLiveStream(String symbol);
Future<void> stopLiveStream();
}
@FlutterApi()
abstract class MarketDataListener {
void onPriceUpdate(PricePoint point);
void onStreamError(String symbol, String error);
}
Notice the annotations: @HostApi() for methods called from Flutter to native code, and @FlutterApi() for callbacks from native code back to Dart. This is the entire contract—nothing else needs to exist in your head when you're working across platforms.
Run flutter pub run pigeon --input pigeons/market_api.dart and Pigeon generates type-safe stubs in Dart, Kotlin, and Swift. No manual message packing or unpacking. No casting. No runtime surprises.
Pigeon generates Dart, Kotlin and Swift
After running the codegen, you get generated files in your native projects (usually in ios/Runner/GeneratedPluginRegistrant and android/app/src/main/kotlin) plus generated Dart code in your Flutter project.
On the Dart side, the generated code gives you strongly-typed classes:
// Generated by Pigeon
class MarketApi {
Future<MarketDataResponse> fetchPrices(MarketDataRequest request) async {
// Marshalling happens automatically
final BasicMessageChannel<Object?> channel =
BasicMessageChannel<Object?>(
'dev.example.market/MarketApi.fetchPrices',
StandardMessageCodec(),
);
final Map<Object?, Object?> replyMap =
await channel.send(<Object?>[request]) as Map<Object?, Object?>;
// Type-safe response parsing
if (replyMap['error'] != null) {
throw PlatformException(...);
}
return MarketDataResponse.fromMap(replyMap['result']);
}
}
In Kotlin, you get an implementation skeleton:
// Generated by Pigeon
abstract class MarketApi {
fun fetchPrices(request: MarketDataRequest, callback: (Result<MarketDataResponse>) -> Unit)
fun startLiveStream(symbol: String, callback: (Result<Unit>) -> Unit)
fun stopLiveStream(callback: (Result<Unit>) -> Unit)
}
You implement this interface with your actual logic:
// Your Kotlin implementation
class MarketApiImpl : MarketApi {
override fun fetchPrices(
request: MarketDataRequest,
callback: (Result<MarketDataResponse>) -> Unit
) {
try {
val api = MarketDataProvider() // Your business logic
val prices = api.getPrices(request.symbol, request.timeframeSeconds)
val response = MarketDataResponse(
symbol = request.symbol,
points = prices.map {
PricePoint(
open = it.o,
high = it.h,
low = it.l,
close = it.c,
timestamp = it.ts.toInt()
)
},
isLive = false
)
callback(Result.success(response))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
override fun startLiveStream(symbol: String, callback: (Result<Unit>) -> Unit) {
// Streaming logic
}
override fun stopLiveStream(callback: (Result<Unit>) -> Unit) {
// Stop logic
}
}
Swift gets the same treatment with generated protocols and structs that match your Dart definitions exactly. The beauty is that the contracts are identical—you can't accidentally send a string where an int is expected, because the type system won't let you.
No manual message parsing
The pain point in manual platform channels is marshalling and unmarshalling. You're writing serialization logic by hand, testing it in isolation, and hoping nothing breaks when the other side changes.
Pigeon handles all of this. When your Dart code calls fetchPrices(MarketDataRequest(symbol: 'AAPL', timeframeSeconds: 300)), Pigeon's generated code:
- Serializes the
MarketDataRequestto a map with proper type checking - Sends it over the channel
- Your native code receives it as a typed
MarketDataRequestobject, not a raw map - You implement your logic
- Your response
MarketDataResponseis automatically serialized back - Dart receives it as a fully-typed
MarketDataResponseready to use
No call.argument<String>("symbol") nullable guessing. No result as? MarketDataResponse unsafe casts in Kotlin. No as! Dictionary force-unwraps in Swift.
This matters because in production, when a market data provider changes their response schema, you update your Pigeon definition and regenerate. Compile errors surface immediately in all three languages. No runtime surprises at 3 AM when your trading app crashes because a field is now nullable.
Fewer runtime type errors
I've spent enough time debugging "type mismatch on platform channel" errors to appreciate what Pigeon brings: compile-time safety that scales.
In a manual channel setup, these errors only show up when you actually call the method at runtime:
- Dart sends a list but Kotlin expects a map
- Native code returns a string instead of an integer
- A required field is omitted from the request
- An enum value doesn't exist in the native implementation
With Pigeon, your IDE catches most of these before you even run the app. The type definitions are in Dart, which your IDE understands. When you change MarketDataResponse, the generated Dart setters are instantly incompatible with old Kotlin code. You can't build until everything is consistent.
In a CRM platform I built, we had dozens of platform channel methods for syncing customer data, managing local storage, and handling push notifications. Manual channels meant managing error cases in three places. Pigeon generated all the error handling infrastructure—request IDs, timeouts, retry logic—from a single definition.
Common mistakes
- Forgetting to run codegen after editing the Pigeon file. You change your Dart API definition but don't regenerate. The native code still implements the old contract. Runtime confusion ensues. Use
flutter pub run pigeonin a pre-build step or watch mode. - Using mutable collections in Pigeon definitions. Pigeon expects immutable types. Use
ListnotArrayList, immutable classes not data classes with var fields. It catches most of this, but it's worth knowing. - Not version-controlling generated files. Generated code should live in your repo so CI/CD doesn't re-generate with a different version of Pigeon. Pin your Pigeon version in pubspec.yaml.
- Mixing Pigeon and manual channels in the same app. You can do it, but it defeats the purpose. Pick one approach and stick with it. Migration is usually worth the one-time effort.
- Ignoring error handling. Pigeon generates error callbacks, but you still need to handle them gracefully in your Dart code. A native crash still bubbles up as a platform exception—catch it and handle it.
Key takeaways
- Pigeon generates type-safe boilerplate from a single Dart API definition, eliminating manual message parsing across Dart, Kotlin, and Swift.
- Define your native API contract once using
@HostApi()and@FlutterApi()annotations, then runflutter pub run pigeonto generate all platform stubs. - The generated code handles serialization, deserialization, and error marshalling automatically—no casting, no nullable guessing, no runtime type surprises.
- Refactoring becomes safe: change your contract in Dart and you get compile errors in all three languages until everything is consistent.
- Use Pigeon for any non-trivial platform channel communication; the initial setup pays for itself in reduced debugging and faster iteration.






Comments
Loading comments…