What can I do with “Extension Types” in Dart?

Yii Chen
Flutter Community
Published in
10 min readJun 20, 2024

Extension Types from Dart 3.3

The official document begins by mentioning:

“An extension type is a compile-time abstraction that “wraps” an existing type with a different, static-only interface.”

Extension Types are a compile-time abstraction, acting as a powerful type wrapper. They are introduced for performance optimization and enhanced interaction with native code. As zero-cost wrappers, they eliminate the typical memory costs associated with Wrapper Classes and Helper Classes when communicating with other languages.

Regular Class Wrappers operate at runtime, always incurring the overhead of Class and Object usage, thereby increasing memory usage and GC(Garbage Collection) costs. In scenarios where many Wrapper instances are created in a short period, this becomes a significant burden.

Extension Types are verified at compile time as extensions of specific types, so their abstraction disappears at runtime, reverting to the original Representation Type. Thus, using Extension Types is cost-free for applications, making them a very efficient development method.

Extension Types benefit static JavaScript interop, allowing seamless interaction with existing JavaScript types.

Benefits

Flexible Constraints and Extensions

They allow the enhancement of existing types, such as int or String, by adding properties, functions, and other APIs.

Clearer Abstractions

They hide the complexity of the underlying Representation Type, enabling more meaningful extensions, thereby improving code readability and maintainability.

Convenient and Safe Interoperability

Custom Dart types are as straightforward as accessing the underlying types, providing type safety. This is especially useful for interoperability with native platforms and other languages, simplifying the process.

Enhanced Performance

They avoid creating Wrapper Classes for each service or specific low-level communication, thus incurring no additional memory overhead. They are ideal for performance-sensitive scenarios, particularly when dealing with large data sets or frequent object operations.

Development

An Extension Type, when declared, has a primary constructor by default.

extension type MyId(int id) {}

void main(List<String> arguments) {
final id = MyId(1);

print(id); // 1
print(id.runtimeType); // int
}

In any case, the type wrapped by Extension Type is called “Representation Type” and is not a subtype. Therefore, in general, Representation Type and custom Extension Type cannot assign values ​​to each other.

There will be no functional operations when there are no customized new attributes and function interfaces. The original behavior of int is restricted.

For existing types, further extensions, or constraints of types, only the APIs that can be used are exposed, thereby avoiding some impermissible operations. We can add some meaningful functions to it that we need

extension type MyId(int id) {
operator >(MyId other) => id > other.id;

bool isBiggerThan(MyId other) => id > other.id;
}

void main(List<String> arguments) {
MyId safeId = MyId(200);
safeId + 10; // Compile error: No '+' operator.
safeId - 10; // Compile error: No '-' operator.
safeId > 10; // Compile error: Wrong type.
safeId > MyId(300); // ✅

int number = 100;
number = safeId; // Compile error: Wrong type.
number = safeId as int; // ✅ Cast to representation type.
safeId = number as MyId; // ✅ Cast to extension type.

print(safeId.isBiggerThan(MyId(300))); // false
}

Readability improves when using an int to carry a value returned after interacting with a service or native API. This can be given a specific name through the Extension Type, making it easier to understand at a glance.

Extension Types and Representation Types can be directly converted using as casting. Interestingly, they can be forcibly converted even though they are not in an inheritance relationship.

i = id as int; // ✅
i = -1;
id = i as Id; // ✅

Here’s an example where we can treat the extended type as a regular Dart class, instantiate it, and call custom functions. Dart compiles it as a normal int.

extension type Wrapper(int i) {
void showValue() {
print('my value is $i');
}
}

void main() {
final wrapper = Wrapper(42);
wrapper.showValue(); // Prints 'my value is 42'
}

The official explanation states that Extension Types are beneficial for interoperability with native code, allowing the direct use of native types without creating wrappers and incurring indirect costs while providing a clean Dart API.

Generics

Using generics with Extension Types:

extension type MyList<T>(List<T> elements) {
void add(T value) => elements.add(value);
}

void main(List<String> arguments) {
MyList list = MyList<int>([1, 2]);
list.add(3);

final normalList = list as List<int>;
print(list); // [1, 2, 3]
print(normalList); // [1, 2, 3]
}

Constructors

Extension Types can have multiple constructors:

  • General constructors
  • Named constructors
  • Hidden constructors using private constructor syntax _
extension type Password._(String value) {
Password(this.value) {
assert(value.length >= 8);

if (value.length < 8) {
throw Exception('Password must be at least 8 characters long');
}
}

Password.random() : value = _generateRandomPassword();

static String _generateRandomPassword() => ...;

bool get isValid => value.length >= 8;
}

void main(List<String> arguments) {
// Implicit unnamed constructor.
Password password = Password('abcdefghijklmnopqrstuvwxyz'); // ✅

// Named constructor.
password = Password.random(); // ✅
password = Password('hello12'); // Exception: Password must be at least 8 characters long
password = 'hello' as Password; // ✅
}

Remember, you cannot use assert() checks or other operations in the implicit primary constructor. Override the primary constructor and add assert checking. Make the original primary constructor private.

extension type Password._(String value) {

Password(this.value) {
assert(value.length >= 8);

if (value.length < 8) {
throw Exception('Password must be at least 8 characters long');
}
}

}

Safe Aliases

Using implements allows Extension Types to expose the underlying type, enabling access to all members of the Representation type, along with any custom helper APIs. This provides the original type's capabilities while also offering aliases and type safety checks.

extension type Height(double _) implements double {}
extension type Weight(double _) implements double {}

double calculateBmi(Height height, Weight weight) => weight / ( height * height);

void main() {
var height = Height(1.75);
var weight = Weight(65);
var bmi = calculateBmi(height, weight);
print(bmi); // 21.22448979591837

bmi = calculateBmi(1.64, 54.0); // ❌ compile-time error
bmi = calculateBmi(weight, height); // ❌ compile-time error
}

When used as function parameters, different aliases, although having common capabilities, cannot be used interchangeably. This differs from the typedef alias, which cannot benefit from the compiler's type-checking.

Adding a new interface to an existing type:

extension type MyId(int id) implements int {
MyId get value => this;
}

void main(List<String> arguments) {
final safeId = MyId(100);
safeId + 1; // 101
safeId - 1; // 99
safeId * 2; // 200
safeId / 2; // 50
safeId % 3; // 1
safeId.toString(); // '100'

int normalId = safeId; // 100
final safeId2 = safeId + normalId; // 200
final safeId3 = 10 + safeId; // 110
}

Redefine the original functional behavior, and the members of Extension Type completely replace the members of the parent type with the same name, providing a new implementation method.

extension type MyId(int id) implements int {
bool get isEven => true;
}

void main(List<String> arguments) {
final myId = MyId(101);
print(myId.isEven); // true
}

Other Scenarios

Multi-Type Extensions

Usually, Extension Types extend one type. When there is multiple information, you can use Record.

typedef UserInfo = ({String email, String password});

extension type User(UserInfo info) {
void printInfo() => print("Email: ${info.email}, Password: ${info.password}");
}

void main(List<String> arguments) {
final user = User(
(
email: 'extension@gmail.com',
password: 'types',
),
);
user.printInfo(); // Email: extension@gmail.com, Password: types
}

Mock Data for Testing

Using Extension Types in testing is also feasible. According to the Mock Class, we only need to tweak the code slightly. The difference is that regular classes would show compile warnings if interfaces are not implemented, whereas Extension Types do not.

abstract final class Repository {
String getToken();
}

final class MyRepository implements Repository {
@override
String getToken() {
return 'hello world';
}
}

extension type MockRepository(Repository repository) implements Repository {
String getToken() {
return 'Testing';
}
}

void main(List<String> arguments) {
final myRepository = MyRepository();
final mockRepository = MockRepository(myRepository);
print(mockRepository.getToken()); // Testing
}

JSON Access

Using Extension Types for JSON data access.

final userMap = json.decode(r'''
{
"name": {
"first": "Yii",
"last": "Chen"
},
"email": "ab20803@gmail.com"
}
'''); // Map<String, dynamic>

extension type User(Map<String, dynamic> _) {
Name get name => _['name'] as Name;
String get email => _['email'] as String;
}
extension type Name(Map<String, dynamic> _) {
String get first => _['first'] as String;
String get last => _['last'] as String;
}
void main() {
final person = User(userMap);
print(person.name.first); // Yii
print(person.name.last); // Chen
print(person.email); // ab20803@gmail.com
print(person.email.length); // 17
}

Interoperability

Currently, Extension Types are primarily used in the dart:js_interop package, allowing access to JavaScript APIs and interactions using familiar syntax. This package defines many JS-related types, such as JSObject and JSAny, ensuring safe communication between Dart and native platforms. Other languages like C++ can also benefit from this approach.

external → This keyword allows us to access external functions, which are commonly from another language. Therefore, it is often seen in Dart interoperability development.

@JS() → If the Dart side wants to implement a different name or write multiple Dart APIs pointing to the same JavaScript API, it can define the name of the JS API for interoperability.

Package Example

flutter_soloud is an audio engine and package developed with C++ at its core, providing a low-latency, high-performance player. From the source code, when we want to execute playback functionality, it starts with the play function in player.cpp and finally executes soloud.play() on the Dart side to obtain the audio task's ID.

A custom SoundHandle wraps the id to ensure readability and performance.

final soloud = SoLoud.instance;
await soloud.init();
final source = await soloud.loadAsset('path/to/asset.mp3');

SoundHandle soundHandle = await soloud.play(source); // id(int)

await soloud.stop(soundHandle);
await soloud.disposeSource(soundHandle);

Here is a quick look at the entire API operation process:

  1. Use player.cpp's play() to obtain the audio handle.
https://github.com/alnitak/flutter_soloud/blob/bd77bb113fa9697b7d33ac10f75988ed72e312e6/src/player.cpp#L267

2. Use bindings_player_ffi.dart to handle the communication between both sides, wrapping the obtained handle ID with SoundHandle.

https://github.com/alnitak/flutter_soloud/blob/bd77bb113fa9697b7d33ac10f75988ed72e312e6/lib/src/bindings_player_ffi.dart#L396C25-L396C36

3. On the Flutter side, use soloud.dart to call play(), which will return a result of type Record. Extract its newHandle, which is the ID we need.

https://github.com/alnitak/flutter_soloud/blob/bd77bb113fa9697b7d33ac10f75988ed72e312e6/lib/src/soloud.dart#L844

4. On the Dart side, the id is wrapped using SoundHandle, which is a meaningful Extension Type. It is then used for further audio control operations.

https://github.com/alnitak/flutter_soloud/blob/bd77bb113fa9697b7d33ac10f75988ed72e312e6/lib/src/sound_handle.dart

Advanced Usage

Factory Constructor: Another type of extension can be applied to the Representation Type. This allows operations to be reused across multiple extension types (similar to multiple inheritance).

extension type Number(int i) {
const factory Number.zero() = Number2;
}

extension type Number2(int i) implements Number {
const Number2(int value) : this(i: value);
}

Remember

Extension Type is a compile-time wrapping behavior. It does not exist at runtime. At runtime, any type of query or operation applies to the Representation Type. This makes Extension Type an unsafe abstraction because the original Representation Type can always be found and the underlying object can be accessed at runtime.

extension type Id(int value) {}

void idToInt() {
var id = Id(1);

// Run-time type of 'id' is representation type 'int'.
if (id is int) print(id.value); // 1

// Can use 'int' methods on 'id' at run time.
if (id case int x) print(x.toString()); // 1
switch (id) {
case int(:final isEven):
print("$id (${isEven ? "even" : "odd"})"); // 1 (odd)
}
}

void intToId() {
int i = 2;

if (i is Id) print("It is"); // It is

if (i case Id id) print("value: ${id.value}"); // value: 2

switch (i) {
case Id(:var value):
print("value: $value"); // value: 2
}
}

Summary

  • Extension Types can constrain existing types and also can provide new capabilities.
  • Understanding the nature of Extension Types is crucial. They only exist at compile time and are ignored at runtime, represented by the Representation Type.
  • Extension Types can save costs in some situations and significantly improve performance, especially in interoperability development.

Extensions Comparison

  • Extension Methods: Suitable for adding simple functionalities to existing types.
  • Extension Types: Enhance existing types, implement complex functionalities, and optimize interoperability with other programming languages.

--

--

Yii Chen
Flutter Community

Flutter Lover || Organizer FlutterTaipei || Writer, Speaker || wanna make Flutter strong in Taiwan. https://linktr.ee/yiichenhi