Dart Object-Oriented Programming

Extension Methods

45 min Lesson 12 of 24

What Are Extension Methods?

Have you ever wished you could add a new method to String, int, or List without modifying the original class? Extension methods let you do exactly that. They allow you to add new functionality to existing types -- including built-in types and third-party library types -- without subclassing or modifying the original code.

The syntax is: extension ExtensionName on TargetType { ... }. Once defined, you can call your extension methods on any instance of the target type as if they were built-in methods.

Your First Extension

// Add methods to the built-in String type
extension StringExtras on String {
  // Check if the string is a valid email
  bool get isEmail {
    return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this);
  }

  // Capitalize the first letter
  String get capitalized {
    if (isEmpty) return this;
    return '${this[0].toUpperCase()}${substring(1)}';
  }

  // Truncate with ellipsis
  String truncate(int maxLength) {
    if (length <= maxLength) return this;
    return '${substring(0, maxLength)}...';
  }

  // Count word occurrences
  int get wordCount => trim().isEmpty ? 0 : trim().split(RegExp(r'\s+')).length;
}

void main() {
  // Use extension methods as if they were built-in
  print('hello world'.capitalized);           // Hello world
  print('test@email.com'.isEmail);              // true
  print('not-an-email'.isEmail);                // false
  print('A very long sentence here'.truncate(10)); // A very lon...
  print('Hello beautiful world'.wordCount);     // 3
  print(''.wordCount);                           // 0
}
Key Concept: Inside an extension, this refers to the instance the method is called on. So in 'hello'.capitalized, this is the string 'hello'. Extensions can add methods, getters, setters, and operators -- but NOT instance fields (state). They are purely about adding behavior.

Extending Numeric Types

Extensions on numeric types are incredibly useful for making your code more readable and expressive. Instead of writing utility functions, you can chain methods directly on numbers.

Numeric Extensions

extension IntExtras on int {
  // Duration helpers -- makes Duration creation readable
  Duration get seconds => Duration(seconds: this);
  Duration get minutes => Duration(minutes: this);
  Duration get hours => Duration(hours: this);
  Duration get days => Duration(days: this);
  Duration get milliseconds => Duration(milliseconds: this);

  // Check properties
  bool get isEven => this % 2 == 0;
  bool get isPrime {
    if (this < 2) return false;
    for (int i = 2; i * i <= this; i++) {
      if (this % i == 0) return false;
    }
    return true;
  }

  // Repeat an action
  void times(void Function(int) action) {
    for (int i = 0; i < this; i++) {
      action(i);
    }
  }

  // Range generation
  List<int> to(int end) =>
      [for (var i = this; i <= end; i++) i];

  // Format with padding
  String padded(int width) => toString().padLeft(width, '0');
}

extension DoubleExtras on double {
  // Currency formatting
  String toCurrency({String symbol = '\$'}) =>
      '$symbol${toStringAsFixed(2)}';

  // Percentage formatting
  String toPercent({int decimals = 1}) =>
      '${(this * 100).toStringAsFixed(decimals)}%';

  // Clamp between 0 and 1
  double get normalized => clamp(0.0, 1.0);
}

void main() {
  // Duration helpers
  var timeout = 5.seconds;
  var delay = 300.milliseconds;
  print(timeout);  // 0:00:05.000000
  print(delay);    // 0:00:00.300000

  // Number checks
  print(7.isPrime);   // true
  print(10.isPrime);  // false

  // Repeat
  3.times((i) => print('Iteration $i'));
  // Iteration 0
  // Iteration 1
  // Iteration 2

  // Range
  print(1.to(5));  // [1, 2, 3, 4, 5]

  // Formatting
  print(42.padded(5));          // 00042
  print(29.99.toCurrency());    // $29.99
  print(0.856.toPercent());     // 85.6%
}
Tip: The Duration extension pattern (5.seconds, 300.milliseconds) is used in many popular Flutter packages. It makes code dramatically more readable: compare await Future.delayed(5.seconds) with await Future.delayed(Duration(seconds: 5)). This single extension can improve readability across your entire codebase.

Named Extensions & Visibility

All extensions should be named. Named extensions can be selectively imported, avoiding conflicts when two extensions add the same method to the same type. You can also make extensions private to a file by prefixing the name with underscore.

Named Extensions and Import Control

// file: string_extensions.dart
extension StringValidators on String {
  bool get isNumeric => RegExp(r'^\d+$').hasMatch(this);
  bool get isAlpha => RegExp(r'^[a-zA-Z]+$').hasMatch(this);
  bool get isAlphaNumeric => RegExp(r'^[a-zA-Z0-9]+$').hasMatch(this);
}

extension StringFormatters on String {
  String get snakeCase =>
      replaceAllMapped(RegExp(r'[A-Z]'), (m) => '_${m[0]!.toLowerCase()}')
          .replaceFirst(RegExp(r'^_'), '');

  String get camelCase {
    var words = split(RegExp(r'[_\s-]+'));
    return words.first.toLowerCase() +
        words.skip(1).map((w) => w.capitalized).join();
  }

  String get capitalized {
    if (isEmpty) return this;
    return '${this[0].toUpperCase()}${substring(1).toLowerCase()}';
  }
}

// Private extension -- only visible in this file
extension _InternalHelpers on String {
  String get _reversed => split('').reversed.join();
}

// Selective import in another file:
// import 'string_extensions.dart' show StringValidators;
// -- Only StringValidators methods are available, not StringFormatters

void main() {
  // StringValidators
  print('12345'.isNumeric);       // true
  print('hello'.isAlpha);          // true
  print('abc123'.isAlphaNumeric);  // true

  // StringFormatters
  print('myVariableName'.snakeCase);  // my_variable_name
  print('hello_world'.camelCase);     // helloWorld
  print('hello'.capitalized);         // Hello
}
Warning: If two extensions define the same method on the same type, you get a compile error when calling that method. To resolve this, either: (1) use show/hide in imports to select which extension you want, or (2) call the extension method explicitly: StringValidators('test').isEmail. Always give your extensions descriptive names to make selective imports clear.

Generic Extensions

Extensions can be generic, adding methods to generic types. This is particularly powerful for extending collections like List<T>, Map<K, V>, and Iterable<T>.

Generic List Extensions

extension ListExtras<T> on List<T> {
  // Get a random element
  T get random => this[DateTime.now().microsecond % length];

  // Split list into chunks of given size
  List<List<T>> chunked(int size) {
    var chunks = <List<T>>[];
    for (var i = 0; i < length; i += size) {
      var end = (i + size < length) ? i + size : length;
      chunks.add(sublist(i, end));
    }
    return chunks;
  }

  // Get unique elements (preserving order)
  List<T> get unique {
    var seen = <T>{};
    return where((item) => seen.add(item)).toList();
  }

  // Safe element access -- returns null instead of throwing
  T? elementAtOrNull(int index) {
    if (index < 0 || index >= length) return null;
    return this[index];
  }

  // Sort and return a new list (non-mutating)
  List<T> sortedBy<R extends Comparable>(R Function(T) selector) {
    var copy = List<T>.from(this);
    copy.sort((a, b) => selector(a).compareTo(selector(b)));
    return copy;
  }
}

// Extension specifically for List of Comparable items
extension ComparableListExtras<T extends Comparable> on List<T> {
  T get maxValue => reduce((a, b) => a.compareTo(b) > 0 ? a : b);
  T get minValue => reduce((a, b) => a.compareTo(b) < 0 ? a : b);
}

// Extension specifically for List of numbers
extension NumListExtras on List<num> {
  num get sum => fold(0, (total, n) => total + n);
  double get average => sum / length;
}

void main() {
  var numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5];

  // Generic extensions
  print(numbers.unique);              // [3, 1, 4, 5, 9, 2, 6]
  print(numbers.chunked(3));          // [[3, 1, 4], [1, 5, 9], [2, 6, 5]]
  print(numbers.elementAtOrNull(99)); // null (instead of RangeError)

  // Comparable extensions
  print(numbers.maxValue);  // 9
  print(numbers.minValue);  // 1

  // Num extensions
  print(numbers.sum);      // 36
  print(numbers.average);  // 4.0

  // Works with any type
  var words = ['banana', 'apple', 'cherry', 'apple'];
  print(words.unique);     // [banana, apple, cherry]
  print(words.maxValue);   // cherry

  // sortedBy with complex objects
  var users = [
    {'name': 'Charlie', 'age': 30},
    {'name': 'Alice', 'age': 25},
    {'name': 'Bob', 'age': 35},
  ];
  var sorted = users.sortedBy((u) => u['name'] as String);
  print(sorted.map((u) => u['name']));  // (Alice, Bob, Charlie)
}

Extension Operators

Extensions can also add operators to existing types. This is useful for making domain-specific operations feel natural.

Adding Operators via Extensions

extension DateTimeExtras on DateTime {
  // Add days using + operator with int
  DateTime operator +(Duration duration) => add(duration);
  DateTime operator -(Duration duration) => subtract(duration);

  // Readable formatting
  String get formatted =>
      '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';

  String get timeFormatted =>
      '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';

  // Relative time
  String get timeAgo {
    var diff = DateTime.now().difference(this);
    if (diff.inDays > 365) return '${diff.inDays ~/ 365}y ago';
    if (diff.inDays > 30) return '${diff.inDays ~/ 30}mo ago';
    if (diff.inDays > 0) return '${diff.inDays}d ago';
    if (diff.inHours > 0) return '${diff.inHours}h ago';
    if (diff.inMinutes > 0) return '${diff.inMinutes}m ago';
    return 'just now';
  }

  // Check date properties
  bool get isToday {
    var now = DateTime.now();
    return year == now.year && month == now.month && day == now.day;
  }

  bool get isWeekend =>
      weekday == DateTime.saturday || weekday == DateTime.sunday;

  // Start/end of day
  DateTime get startOfDay => DateTime(year, month, day);
  DateTime get endOfDay => DateTime(year, month, day, 23, 59, 59);
}

extension MapExtras<K, V> on Map<K, V> {
  // Merge with another map (other wins on conflicts)
  Map<K, V> operator +(Map<K, V> other) => {...this, ...other};

  // Get value or default without null check
  V getOr(K key, V defaultValue) => this[key] ?? defaultValue;

  // Filter entries
  Map<K, V> whereEntries(bool Function(K, V) test) {
    return Map.fromEntries(
      entries.where((e) => test(e.key, e.value)),
    );
  }
}

void main() {
  var now = DateTime.now();
  print(now.formatted);        // 2026-04-15
  print(now.timeFormatted);    // 14:30
  print(now.isWeekend);        // depends on current day
  print(now.startOfDay);       // 2026-04-15 00:00:00.000

  var past = DateTime(2026, 4, 10);
  print(past.timeAgo);         // 5d ago

  // Map operators
  var map1 = {'a': 1, 'b': 2};
  var map2 = {'b': 3, 'c': 4};
  print(map1 + map2);  // {a: 1, b: 3, c: 4}

  print(map1.getOr('z', 0));  // 0

  var filtered = map1.whereEntries((k, v) => v > 1);
  print(filtered);  // {b: 2}
}

Practical Example: Building a Validation Library

Let’s build a real-world validation library using extensions. This pattern is used in production Flutter apps to validate form inputs cleanly and chainably.

Real-World: Validation Extension Library

// Core validation result
class ValidationResult {
  final bool isValid;
  final String? error;

  const ValidationResult.valid() : isValid = true, error = null;
  const ValidationResult.invalid(this.error) : isValid = false;

  @override
  String toString() => isValid ? 'Valid' : 'Invalid: $error';
}

// Chainable validator
class Validator {
  final String value;
  final List<String> _errors = [];

  Validator(this.value);

  bool get isValid => _errors.isEmpty;
  List<String> get errors => List.unmodifiable(_errors);
  String? get firstError => _errors.isEmpty ? null : _errors.first;

  Validator required({String? message}) {
    if (value.trim().isEmpty) {
      _errors.add(message ?? 'This field is required');
    }
    return this;
  }

  Validator minLength(int min, {String? message}) {
    if (value.length < min) {
      _errors.add(message ?? 'Must be at least $min characters');
    }
    return this;
  }

  Validator maxLength(int max, {String? message}) {
    if (value.length > max) {
      _errors.add(message ?? 'Must be at most $max characters');
    }
    return this;
  }

  Validator matches(RegExp pattern, {String? message}) {
    if (!pattern.hasMatch(value)) {
      _errors.add(message ?? 'Invalid format');
    }
    return this;
  }

  Validator custom(bool Function(String) test, String message) {
    if (!test(value)) {
      _errors.add(message);
    }
    return this;
  }
}

// Extension that bridges String to Validator
extension StringValidation on String {
  Validator get validate => Validator(this);

  // Quick validators
  ValidationResult validateEmail() {
    var v = validate
        .required(message: 'Email is required')
        .matches(
          RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'),
          message: 'Invalid email format',
        );
    return v.isValid
        ? ValidationResult.valid()
        : ValidationResult.invalid(v.firstError!);
  }

  ValidationResult validatePassword() {
    var v = validate
        .required(message: 'Password is required')
        .minLength(8, message: 'Password must be at least 8 characters')
        .matches(
          RegExp(r'[A-Z]'),
          message: 'Must contain an uppercase letter',
        )
        .matches(
          RegExp(r'[0-9]'),
          message: 'Must contain a number',
        );
    return v.isValid
        ? ValidationResult.valid()
        : ValidationResult.invalid(v.firstError!);
  }

  ValidationResult validateUsername() {
    var v = validate
        .required(message: 'Username is required')
        .minLength(3, message: 'Username must be at least 3 characters')
        .maxLength(20, message: 'Username must be at most 20 characters')
        .matches(
          RegExp(r'^[a-zA-Z0-9_]+$'),
          message: 'Only letters, numbers, and underscores',
        );
    return v.isValid
        ? ValidationResult.valid()
        : ValidationResult.invalid(v.firstError!);
  }
}

void main() {
  // Quick validation
  print('alice@test.com'.validateEmail());   // Valid
  print('not-email'.validateEmail());          // Invalid: Invalid email format
  print(''.validateEmail());                    // Invalid: Email is required

  print('SecurePass1'.validatePassword());     // Valid
  print('weak'.validatePassword());             // Invalid: Password must be at least 8 characters
  print('alllowercase1'.validatePassword());    // Invalid: Must contain an uppercase letter

  print('alice_123'.validateUsername());        // Valid
  print('ab'.validateUsername());                // Invalid: Username must be at least 3 characters

  // Custom chain validation
  var result = 'my-input'.validate
      .required()
      .minLength(5)
      .custom(
        (s) => !s.contains('bad-word'),
        'Contains prohibited content',
      );

  if (result.isValid) {
    print('Input is valid!');
  } else {
    print('Errors: ${result.errors}');
  }
}
Best Practice: Extension methods are ideal for creating domain-specific vocabularies. In a Flutter app, you might have extensions for formatting dates, validating inputs, converting colors, creating widgets from data, and building durations. Keep extensions organized in separate files by category (string_extensions.dart, date_extensions.dart, list_extensions.dart) and import only what you need. This keeps your codebase clean and avoids naming conflicts.