Extension Methods
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
}
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%
}
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
}
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}');
}
}
string_extensions.dart, date_extensions.dart, list_extensions.dart) and import only what you need. This keeps your codebase clean and avoids naming conflicts.