DEV Community

kanta13jp1
kanta13jp1

Posted on

Dart Extension Types — Zero-Cost Wrappers and Type-Safe Domain Modeling

Dart Extension Types — Zero-Cost Wrappers and Type-Safe Domain Modeling

Introduced in Dart 3.3, Extension Types let you wrap an existing type with zero runtime overhead while gaining domain-specific type safety. More powerful than typedef, cheaper than class.

What Are Extension Types?

An Extension Type is identical to its wrapped type at runtime, but treated as a distinct type by the static type checker.

// ❌ Type-unsafe: wrong argument order compiles fine
void transferMoney(int from, int to, int amount) { ... }
transferMoney(userId, accountId, 1000); // silently wrong

// ✅ Extension Types prevent this at compile time
extension type UserId(int value) {}
extension type AccountId(int value) {}
extension type Money(int cents) {}

void transferMoney(UserId from, AccountId to, Money amount) { ... }
transferMoney(UserId(123), AccountId(456), Money(1000)); // only correct order compiles
Enter fullscreen mode Exit fullscreen mode

Zero runtime overhead — UserId(123) has the same memory representation as the int 123.

Basic Syntax

extension type Celsius(double value) {
  Fahrenheit toFahrenheit() => Fahrenheit(value * 9/5 + 32);

  Celsius operator +(Celsius other) => Celsius(value + other.value);

  bool get isFreezing => value <= 0;
}

extension type Fahrenheit(double value) {
  Celsius toCelsius() => Celsius((value - 32) * 5/9);
}

void main() {
  const temp = Celsius(100.0);
  print(temp.toFahrenheit().value); // 212.0
  print(temp.isFreezing); // false

  // ❌ Compile error: Celsius and Fahrenheit are incompatible types
  // Celsius c = Fahrenheit(32.0);
}
Enter fullscreen mode Exit fullscreen mode

implements to Expose the Underlying Type's API

By default, Extension Types don't expose the wrapped type's methods.

extension type SafeString(String value) implements String {
  // implements String → all String methods are accessible

  bool get isValidEmail => RegExp(r'^[\w-\.]+@[\w-]+\.[a-z]{2,}$')
      .hasMatch(value);
}

void main() {
  const email = SafeString('user@example.com');
  print(email.length);        // ✅ String.length available
  print(email.toUpperCase()); // ✅ String.toUpperCase() available
  print(email.isValidEmail);  // ✅ custom method
}
Enter fullscreen mode Exit fullscreen mode

Domain Modeling in Practice

Separating ID Types

extension type UserId(String value) {
  factory UserId.generate() => UserId(DateTime.now().millisecondsSinceEpoch.toString());

  @override
  String toString() => 'User:$value';
}

extension type PostId(String value) {
  @override
  String toString() => 'Post:$value';
}

// Type-safe Supabase queries
class UserRepository {
  Future<Map<String, dynamic>?> findById(UserId id) async {
    // Passing a PostId is caught at compile time
    final response = await supabase
        .from('users')
        .select()
        .eq('id', id.value)
        .maybeSingle();
    return response;
  }
}
Enter fullscreen mode Exit fullscreen mode

Type-Safe Money Representation

extension type Cents(int value) {
  Cents withTax([double rate = 0.10]) => Cents((value * (1 + rate)).round());

  String get formatted {
    final dollars = value ~/ 100;
    final cents = value % 100;
    return '\$${dollars.toString().replaceAllMapped(
      RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
      (m) => '${m[1]},',
    )}.${cents.toString().padLeft(2, '0')}';
  }

  Cents operator +(Cents other) => Cents(value + other.value);
  Cents operator *(int factor) => Cents(value * factor);
  bool operator <(Cents other) => value < other.value;
  bool operator >(Cents other) => value > other.value;
}

void main() {
  const price = Cents(1000); // $10.00
  print(price.withTax().formatted); // $11.00

  const cart = [Cents(300), Cents(500), Cents(200)];
  final total = cart.reduce((a, b) => a + b);
  print(total.formatted); // $10.00
}
Enter fullscreen mode Exit fullscreen mode

Validated String Types

extension type ValidEmail(String value) {
  static ValidEmail? tryParse(String input) {
    final regex = RegExp(r'^[\w-\.]+@[\w-]+\.[a-z]{2,}$');
    return regex.hasMatch(input) ? ValidEmail(input) : null;
  }

  String get domain => value.split('@').last;
  String get localPart => value.split('@').first;
}

extension type Slug(String value) {
  static Slug fromTitle(String title) {
    final slug = title
        .toLowerCase()
        .replaceAll(RegExp(r'[^\w\s-]'), '')
        .replaceAll(RegExp(r'\s+'), '-')
        .replaceAll(RegExp(r'-+'), '-');
    return Slug(slug);
  }
}
Enter fullscreen mode Exit fullscreen mode

Application in Flutter Widgets

extension type AppColor(Color value) {
  static const primary = AppColor(Color(0xFF6200EE));
  static const secondary = AppColor(Color(0xFF03DAC5));
  static const error = AppColor(Color(0xFFB00020));

  AppColor withOpacity(double opacity) =>
      AppColor(value.withOpacity(opacity));
}

class PrimaryButton extends StatelessWidget {
  const PrimaryButton({
    super.key,
    required this.label,
    required this.onPressed,
    this.backgroundColor = AppColor.primary,
  });

  final String label;
  final VoidCallback onPressed;
  final AppColor backgroundColor;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      style: ElevatedButton.styleFrom(
        backgroundColor: backgroundColor.value,
      ),
      onPressed: onPressed,
      child: Text(label),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Combining with Sealed Classes

sealed class ApiResponse<T> {
  const ApiResponse();
}

extension type ApiData<T>(T data) implements ApiResponse<T> {}
extension type ApiError(String message) implements ApiResponse<Never> {}
extension type ApiLoading(double? progress) implements ApiResponse<Never> {}

Widget buildFromResponse<T>(ApiResponse<T> response, Widget Function(T) builder) {
  return switch (response) {
    ApiData(:final data) => builder(data),
    ApiError(:final message) => ErrorWidget(message),
    ApiLoading(:final progress) => LinearProgressIndicator(value: progress),
  };
}
Enter fullscreen mode Exit fullscreen mode

Extension Types vs typedef

// typedef: just an alias — treated as the same type
typedef UserId = String;
typedef PostId = String;

void example(UserId id) {}
PostId postId = 'post-123';
example(postId); // ✅ compiles (both are String)

// Extension Type: genuinely distinct types
extension type ExtUserId(String value) {}
extension type ExtPostId(String value) {}

void goodExample(ExtUserId id) {}
ExtPostId extPostId = ExtPostId('post-123');
// goodExample(extPostId); // ❌ compile error
Enter fullscreen mode Exit fullscreen mode

When to Use Extension Types

Use Case Extension Type
Separating ID types UserId, PostId, OrderId
Unit-bearing numbers Celsius, Cents, Meters
Validated strings ValidEmail, Slug, PhoneNumber
Design tokens AppColor, SpacingToken
Zero runtime cost required ✅ no overhead

Extension Types are a surgical cure for the Primitive Obsession antipattern — using raw String and int for everything. Let the type system express your domain model.


Building a life-management app that replaces 21 competing SaaS tools with Flutter + Supabase. Follow the journey → @kanta13jp1

Top comments (0)