Dart continues to evolve, gaining new language features. While extension methods have become an everyday tool, extension types remain in the shadows — completely undeservedly. Why has such a powerful mechanism been underappreciated? In what cases is it truly indispensable?
In this article, we’ll reflect on Dart’s evolution in the context of static type extensions and explore practical use cases for extension types.
Hi! My name is Nikita Sin. I’m a lead Flutter developer at betting company BetBoom, the author of the BDUI framework for Flutter — Duit. I’m also the leader of the mobile developers’ community Mobile Assembly | Kaliningrad.
I’d bet that you use static type extensions every day! But, as my experience studying open-source library code and interacting with other developers shows, many Dart users still haven’t fully appreciated the power of extension types. This isn’t just "another way to add a method" — it’s a fundamentally different approach to working with types in Dart!
This situation doesn’t sit well with me. I’m here to show you the full potential of this awesome feature by digging deeper than superficial overviews. Let’s start from the very beginning — with the introduction of extension methods — so we can trace how Dart evolved toward extension types.
The history of static type extensions: Extension Methods
The release of Dart 2.7 on December 11, 2019, brought several changes, including a new feature — extension methods (hereafter, EM).
In a way, this was a revolution, providing developers with a solution to one of the language’s fundamental problems: extending the functionality of closed or third-party types.
Today, it’s simply impossible to imagine code without extensions. Many libraries (like go_router
, bloc
, etc.) rely on them, with a common use case being extending BuildContext
. And all this without modifying source code, creating wrappers, or using inheritance!
But time doesn’t stand still, and the Dart team has introduced entirely new concepts to the language.
Extension Types
While EM allowed adding new methods to existing types, the introduction of extension types (hereafter, ET) in Dart 3.3 took things further — enabling the creation of new static types on top of existing implementations.
This is a fundamentally different level of abstraction. But as I began studying this feature more closely, I encountered an interesting paradox: despite its depth and capabilities, ET "remain in the shadows," underutilized by most developers.
The situation becomes even more intriguing when you realize that the only widespread example of ET usage is package:web, one of the key packages for the Dart/Flutter ecosystem. Outside of web development, they’re almost absent in popular packages.
Does this mean the feature is weak, or have we simply not yet realized its potential? Let’s figure it out!
Key similarities and fundamental differences
I remember my first encounter with ET. At first, it was completely unclear why we needed an "analog" of the already existing EM, since at a glance, they seemed very similar:
- Both EM and ET work with existing types, adding functionality to types you can’t or don’t want to modify directly (e.g., types from
dart:core
, classes from third-party libraries). - Neither EM nor ET create additional objects in memory when called — the compiler "unwraps" them during compilation.
- At runtime, the value remains an instance of the original type (
int
,String
, etc.). No surprises with sudden type changes. - Both mechanisms allow adding methods, getters, setters, and overriding operators that work with instances of the type.
But all of the above is just the tip of the iceberg. Despite their apparent similarity, the differences between the two mechanisms are significant, both in terms of capabilities and conceptually. Let’s examine them in more detail.
- While EM "simply" adds new methods to an instance of an existing type, ET creates a new type based on the underlying one, effectively introducing an abstraction that the compiler treats as an independent type.
- EM is automatically compatible with all instances of the extended class (e.g., every
int
has methods fromextension on int
). ET, however, requires explicit "wrapping" of the base type instance and explicit type casting. - ET supports static members — you can declare static methods, fields, named constructors, and factory constructors.
- One of ET’s key features is member hiding, allowing control over the API of any type in Dart.
- ET carries not only new or overridden functionality for the base type but also semantic meaning. EM, on the other hand, is perceived more as a set of utility methods.
Extension types in action: package:web
With theory out of the way, let’s look at a real-world example of ET usage. I wondered: "Why were ET used in package:web instead of regular extensions?" and found several key reasons:
Ensuring type safety. Even with extension on JSObject
, all JS objects remain the same type—the compiler doesn’t distinguish between Window
, HTMLElement
, etc. ET creates objects of unique types, even though at runtime they’re still JSObject
. The Dart compiler prevents confusion between objects.
// Without ET: Runtime error only
Window window = getWindow();
window.querySelector('div'); // Window doesn’t have this method!
Abstraction without overhead. The eternal problem with any wrapper classes is extra memory allocations. For performance-critical systems, this is a crucial factor to avoid. ET doesn’t create additional objects at runtime while adding compile-time type safety guarantees. In package:web
, frequent JS-API calls (e.g., in animations or event handling) would cause GC pressure and FPS drops if full wrapper objects were created. ET enables both maximum performance and resource efficiency—the wrapper exists only at the type level, with direct JS-object access at runtime.
JS interaction semantics. The package:js
library worked with the dynamic
type, which was far from ideal. This made the code unsafe, as errors could only be caught at runtime. ET changes the game: all JS objects remain statically typed, and development becomes more convenient thanks to autocompletion.
// Highly simplified ET declaration for JS objects:
extension type Window(JSObject _) implements JSObject {
external Document get document;
external void alert(String message);
}
extension type Document(JSObject _) implements JSObject {
external HTMLElement? querySelector(String selector);
}
extension type HTMLElement(JSObject _) implements JSObject {
external DOMTokenList get classList;
}
Why haven’t Extension Types "taken off"?
The paradox of ET is that their success case has also become their curse. While demonstrating the power of the concept, package:web
is a niche domain for most developers, making ET seem overly complex to learn.
So why don’t we see widespread ET adoption?
Cognitive load. The concept of a static wrapper that exists only at compile-time requires a paradigm shift. Developers are used to two typical models:
- Creating wrapper classes with inherent overhead.
- Using extension methods for utility functions.
ET, however, is a hybrid—you create a new type (e.g., UserId), but it disappears at runtime. This causes dissonance, forcing developers to constantly keep the difference between static and runtime semantics in mind.
Lack of design practices. ET solve a narrow set of problems: optimizing wrapper classes, typed and protected interfaces. But these scenarios rarely arise in everyday app development. When they do, developers don’t associate them with ET. There’s no established understanding of how to design ET or when to choose them over classes.
Synthetic examples. Dart’s documentation offers examples like wrapping an ID over int. While technically correct, this doesn’t showcase ET’s full potential—and frankly, it’s uninspiring. Why use ET when typedef works?
The lack of a "bridge" between UserID(int id)
and package:web
, combined with the need to consider new language features during design, has led to this genuinely cool Dart feature remaining underutilized.
Constructive criticism is good, but there’s a saying: "If you criticize, suggest." I invite you to consider a more "real-world" application of this powerful Dart feature.
Encapsulating internal logic and object-oriented interaction with procedures
Imagine this scenario: you’re working with low-level APIs or a set of procedures. A common challenge developers face is the lack of an expressive API, which can lead to hard-to-diagnose errors.
As an example, let’s take working with isolates, where procedural style dominates, requiring manual port management. This is a powerful but low-level mechanism where mistakes are easy to make. ET offers an alternative, allowing elegant object-oriented contracts on top of low-level mechanisms. Let’s see how Dart’s ET can adapt the base isolate worker implementation for specific tasks.
First, let’s implement a basic worker class:
// Base worker implementation
final class Worker {
final SendPort sP;
final ReceivePort rP;
final Isolate isolate;
Worker._(
this.isolate,
this.sP,
this.rP,
);
static Future<Worker> create(void Function(SendPort) entryPoint) async {
final rp = ReceivePort();
final isolate = await Isolate.spawn(
entryPoint,
rp.sendPort,
);
final sp = await rp.first as SendPort;
return Worker._(isolate, sp, rp);
}
}
// Handler for RemoteMessage events
void _isolateEntryPoint(SendPort sendPort) {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
receivePort.listen(
(message) async {
switch (message) {
case RemoteMessage():
try {
final res = await message.computation();
message.sendPort.send(res);
break;
} catch (e) {
message.sendPort.send(e);
}
default:
throw UnimplementedError();
}
},
);
}
// Handler for String events
void _isolateEntryPoint2(SendPort sendPort) {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
receivePort.listen(
(message) async {
switch (message) {
case String():
print(message);
break;
default:
throw UnimplementedError();
}
},
);
}
Notice that all workers created via the static create method have different entry points (_isolateEntryPoint
and _isolateEntryPoint2
). Yet, the Worker
class itself lacks additional methods for interaction. Let’s fix this with ET.
// Only for printing messages
extension type PrintWorker(Worker worker) {
void print(String message) {
worker.sP.send("Isolate ${Isolate.current.hashCode}: $message");
}
}
// Only for heavy computations
extension type ComputeWorker(Worker worker) {
Future<dynamic> compute(Future<dynamic> Function() computation) async {
final responsePort = ReceivePort();
worker.sP.send(RemoteMessage(
computation,
responsePort.sendPort,
));
return await responsePort.first;
}
}
The key idea is that the base Worker
class hides the general isolate creation logic, while ET adds specialized methods that restrict interaction with the worker.
What does this give us in practice? If you create different workers for different tasks (e.g., one for logging and three for heavy computations) or even manage their lifecycle dynamically, this approach protects against errors when working with the worker’s public API, which is implemented using ET. The entire interaction API is centralized, not scattered across the code, and doesn’t require inheritance hierarchies. Additionally, the code becomes more expressive: compare worker.sendPort.send(() => 2 + 2)
to computeWorker.compute(() => 2 + 2)
.
void main() async {
final w = PrintWorker(await Worker.create(_isolateEntryPoint2));
final w2 = ComputeWorker(await Worker.create(_isolateEntryPoint));
// Only use methods declared in the extension
w.print("Hello from isolate");
w.compute(...); // Error!
final result = await w2.compute(
() async => 2 + 2,
);
print("Computation res: $result");
}
The results:
- We’ve encapsulated all low-level control code, message formatting, and port-sending logic. Users only see semantically meaningful operations.
- New worker types can be added without modifying the base Worker class.
- Unlike implementing this via wrapper classes or inheritance, there’s no overhead (ET don’t create additional objects in memory). At runtime, PrintWorker disappears, leaving only the original Worker—but with compile-time safety guarantees.
Final thoughts
Extension types aren’t just another Dart feature — they’re a fundamentally new way to design abstractions. They offer something unattainable with either extension methods or classes: static type safety without runtime overhead.
Mastering extension types is another step toward professional Dart proficiency. Their use requires creativity, practice, and exposure, but once you find your first real-world use case, you’ll unlock a new level of expressiveness and control in your code.
Top comments (0)