07 · Extension Methods¶
Sometimes you want to add a method to a type you don't control — a built-in
type like String or int, or a class from a package you can't edit. In
most languages you'd have to wrap it or write a free-floating helper
function. Dart's extension lets you add methods and getters to any type,
including built-ins, as if you'd written them into the original class.
Defining an extension¶
extension StringCasing on String {
String get capitalized {
if (isEmpty) return this;
return this[0].toUpperCase() + substring(1);
}
bool get isPalindrome {
final cleaned = toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
return cleaned == cleaned.split('').reversed.join();
}
}
void main() {
print('hello'.capitalized);
// Hello
print('Was it a car or a cat I saw'.isPalindrome);
// true
print('hello world'.isPalindrome);
// false
}
Once StringCasing is imported, every String in that file gets
.capitalized and .isPalindrome — call syntax is indistinguishable from a
method that was always part of String. Inside the extension body, this
refers to the String instance the method was called on, exactly like
inside a normal class method.
Why extensions instead of a free function?¶
Compare text.capitalized to capitalize(text). The extension version
reads left-to-right in the order you think about the operation, chains
naturally with other methods (text.trim().capitalized.toUpperCase()), and
shows up in autocomplete the moment you type text. — a free function
requires knowing it exists and importing it correctly, and doesn't chain as
cleanly.
Extensions work on your own classes too¶
Extensions aren't just for retrofitting built-ins — they're equally useful on your own classes, as a way to keep a core model lean and put derived/convenience behavior (formatting, display logic, math operators) somewhere separate. Extensions can add operators, too.
class Money {
final int cents;
Money(this.cents);
@override
String toString() => '\$${(cents / 100).toStringAsFixed(2)}';
}
// Keep display-only helpers out of the core model...
extension MoneyFormatting on Money {
String get compact {
if (cents >= 100000) return '\$${(cents / 100000).toStringAsFixed(1)}k';
return toString();
}
}
// ...and math operators can live in their own extension too.
extension MoneyMath on Money {
Money operator +(Money other) => Money(cents + other.cents);
}
void main() {
final price = Money(150000);
print(price); // $1500.00
print(price.compact); // $1.5k
final total = Money(500) + Money(750);
print(total); // $12.50
}
This separation is genuinely useful in larger codebases: a Money class
shared across a whole app might live in a core package, while
MoneyFormatting (which might pull in locale-specific rules) lives only in
the UI layer that actually needs it.
Extensions can be generic too¶
extension ListStats<T extends num> on List<T> {
T get sum => isEmpty ? (0 as T) : reduce((a, b) => (a + b) as T);
double get average => isEmpty ? 0 : sum / length;
}
void main() {
print([1, 2, 3, 4].sum); // 10
print([1, 2, 3, 4].average); // 2.5
print(<int>[].average); // 0.0
}
extension ... <T extends num> on List<T> restricts this extension to
lists of numbers specifically — <String>['a', 'b'].sum simply wouldn't
compile, because List<String> isn't a List<num>.
The trap: colliding extension names¶
Two different extensions can add a member with the same name to the same type — perfectly legal on their own, until both happen to be imported into the same file. Then calling that member directly becomes ambiguous, and Dart refuses to guess which one you meant.
extension LoudA on String {
String shout() => '${toUpperCase()}!!!';
}
extension LoudB on String {
String shout() => '${toUpperCase()}...?!';
}
void main() {
// print('hi'.shout());
// Error: A member named 'shout' is defined in 'extension LoudA on String'
// and 'extension LoudB on String', and neither is more specific.
// The fix: disambiguate with an explicit extension override.
print(LoudA('hi').shout()); // HI!!!
print(LoudB('hi').shout()); // HI...?!
}
ExtensionName(value).member() — an extension override — is the escape
hatch: it tells the compiler exactly which extension's implementation to
use, bypassing the ambiguity. This is a real risk once a project pulls in
several packages that each add convenience extensions on common types like
String, DateTime, or List — naming your own extensions specifically
(StringCasing, not something generic) makes collisions easier to spot and
resolve.
Extensions don't add real storage¶
An extension can only add computed getters/methods — it cannot add new instance fields, because it isn't actually part of the class; it's syntax sugar over a static function that takes the receiver as a hidden argument. Anything an extension "adds" has to be derived from members the type already has.
| Can an extension... | |
|---|---|
| Add a method/getter/setter | Yes |
Add an operator (e.g. +) |
Yes |
| Add a new instance field | No — no extra storage exists |
| Override an existing member | No — extensions are picked only when no real member matches |
Be generic (extension E<T> on List<T>) |
Yes |
How It Actually Works¶
Extension methods are a purely compile-time, static-dispatch feature —
there is no runtime object modification happening at all, which is the
single most important mechanism to understand about them. When the compiler
sees someString.isPalindrome, it looks at the static type of
someString and searches for an applicable extension whose on clause
matches, then rewrites the call into an ordinary static function call
(StringExtension.isPalindrome(someString)) before code generation. This is
exactly why extension methods don't show up in runtimeType, don't
participate in is/as checks, can't be overridden polymorphically, and —
critically — are resolved based on the static type of the receiver
expression, not its runtime type: if you have Object o = 'hello' and an
extension only applies to String, calling it through o fails to resolve
even though o's runtime value actually is a String.
Colliding extension names are a compile-time ambiguity, not a runtime
conflict — if two imported extensions both declare a member with the same
name applicable to the same type, the compiler can't determine which one
you mean and requires you to disambiguate (import with a prefix, or use
explicit extension-application syntax ExtensionName(value).member). There
is no "closest match" or override-resolution fallback the way there is for
inherited methods, because extensions don't participate in any inheritance
chain at all.
Because extensions add no real storage, they cannot declare instance
fields with backing storage — an extension "getter" must be computed from
the receiver's existing state on every call (there's nowhere to persist a
new field on an object you don't own), which is also why extensions can't
add fields that participate in a class's ==/hashCode/serialization.
Exercise¶
Write an extension DurationFormatting on Duration with a getter
readable that returns a human-friendly string like "1h 5m 30s" (omit any
unit that's zero — e.g. a duration of exactly 90 seconds should print "1m
30s", not "0h 1m 30s"). Test it against a few Duration values built with
Duration(hours: ..., minutes: ..., seconds: ...), including one under a
minute and one over an hour.