← Back to blog
Object-Oriented Dart · Part 9 of 11
July 2, 202612 min read

Extension Methods & Extension Types in Dart

DartOOPFlutter

Extension Methods & Extension Types

This is Part 9 of the Object-Oriented Dart series. Every technique so far assumed you own the class you're working with — you can add a method because you wrote the class. But what about types you don't own: String, int, List, a class from a package? You can't edit their source.

Dart's answer is extension methods — a way to bolt new methods, getters, and operators onto any existing type as if they'd always been there. We'll also meet extension types, a newer feature for zero-cost type wrappers. Both are genuinely modern, and both surprise people with how clean they make code.


The problem: types you can't modify

Say you constantly need to check if a string is a valid email. Without extensions you'd write a free function:

bool isValidEmail(String s) => s.contains('@') && s.contains('.');

// call site:
if (isValidEmail(email)) { ... }

It works, but it reads "backwards" — the verb comes before the noun, and it doesn't show up in autocomplete when you type email.. You really want email.isValidEmail, as though String had that method all along. You can't add it to String (you don't own it)… except you can.


Defining an extension method

You declare an extension ... on SomeType { ... } and put methods, getters, setters, or operators inside. Within the body, this refers to the value being extended:

extension EmailValidation on String {
  bool get isValidEmail => contains('@') && contains('.');
}

void main() {
  print('hi@example.com'.isValidEmail); // true
  print('nope'.isValidEmail);           // false
}

That's it — String now has an isValidEmail getter everywhere this extension is in scope. Notice we wrote contains('@') with no prefix; inside an extension, the extended value is implicitly this, so its members are in scope directly (we could also write this.contains('@')).

You can add multiple members, including methods that take arguments and operators:

extension StringExtras on String {
  String repeat(int times) => List.filled(times, this).join();
  String get reversed => split('').reversed.join();
  bool get isBlank => trim().isEmpty;
}

void main() {
  print('ab'.repeat(3));   // ababab
  print('hello'.reversed); // olleh
  print('   '.isBlank);    // true
}

This reads beautifully and shows up in IDE autocomplete right alongside the built-in String methods. Extensions are the idiomatic way to build a "utility belt" for common types in your codebase.

Extending your own and package types too

Extensions aren't only for built-ins. Extend DateTime, List, a Flutter BuildContext, or a class from someone else's package:

extension ListStats on List<num> {
  num get sum => fold(0, (a, b) => a + b);
  double get average => isEmpty ? 0 : sum / length;
}

void main() {
  print([1, 2, 3, 4].average); // 2.5
}

In Flutter, the classic example is context.theme or context.screenWidth via an extension on BuildContext — it removes a ton of boilerplate.


The big caveat: extensions are resolved statically

This is the one thing you must understand about extensions, and it's where they differ fundamentally from real methods.

Extension methods are resolved using the static (declared) type of the variable — at compile time — not the runtime type. They are not polymorphic. That has two concrete consequences:

1. They don't work on dynamic. With dynamic, there's no static type to resolve against, so the call fails at runtime:

extension on String {
  int get wordCount => trim().split(RegExp(r'\s+')).length;
}

void main() {
  dynamic d = 'one two three';
  // print(d.wordCount); // 💥 NoSuchMethodError at runtime

  String s = 'one two three';
  print(s.wordCount); // ✅ 3 — static type is String
}

2. They don't override real methods, and they don't dispatch on subtypes. If you call an extension method through a supertype variable, you get the extension chosen for that declared type, regardless of the object's actual runtime class. Unlike the inherited/overridden methods from Part 5, extensions are "dumb" lookups based on what the compiler sees.

The mental model: a true method is part of the object and dispatched at runtime (polymorphic). An extension method is really syntactic sugar over a static functionemail.isValidEmail compiles down to roughly EmailValidation(email).isValidEmail. Knowing this, the static-resolution rules stop being surprising and start being obvious.


Generic extensions

Extensions can be generic, so they work across all type arguments of a generic type. This is how you'd add a safe-access helper to every List:

extension SafeAccess<T> on List<T> {
  T? getOrNull(int index) =>
      (index >= 0 && index < length) ? this[index] : null;
}

void main() {
  final names = ['Sam', 'Alex'];
  print(names.getOrNull(0)); // Sam
  print(names.getOrNull(5)); // null — no RangeError
}

The <T> ties the extension to the list's element type, so getOrNull returns a properly-typed T?. Generic extensions are how packages add broadly-useful helpers without giving up type safety.


Handling name conflicts

What if two imported extensions both define a parseInt on String, or an extension method clashes with a real method? Dart gives you ways to disambiguate:

Real members always win. If the type already has a method with that name, the real method is called — an extension can never override an actual member. (So you can't "monkey-patch" existing behavior; extensions only add.)

Between two extensions, use one of:

import 'string_apis.dart';
import 'other_apis.dart' as other;

// 1. Explicit application syntax — name the extension:
print(NumberParsing('42').parseInt());

// 2. show / hide on the import to bring in only one:
import 'string_apis.dart' show NumberParsing;

// 3. an import prefix:
print(other.OtherParsing('42').parseInt());

The explicit ExtensionName(value).member form is the key tool — it's also how you'd call an extension method on a dynamic value if you really had to.


Extension types: zero-cost wrappers

Extensions add methods to an existing type. Extension types (Dart 3.3+) are a different, newer feature: they create a distinct compile-time type that wraps an existing one — without any runtime wrapper object or allocation. Think "a type-safe alias with its own API that compiles away to nothing."

The motivating problem: an int user ID and an int product ID are both just int, so the compiler happily lets you mix them up:

void deleteUser(int userId) { ... }

int productId = 42;
deleteUser(productId); // 😱 compiles fine — wrong id, no warning

An extension type makes them distinct types while staying an int at runtime:

extension type UserId(int value) {
  // You can add methods/getters here too.
  bool get isValid => value > 0;
}

void deleteUser(UserId id) { ... }

void main() {
  final id = UserId(42);
  deleteUser(id);        // ✅
  // deleteUser(99);     // ❌ compile error — int is not a UserId
  print(id.value);       // 42 — the wrapped int
  print(id.isValid);     // true
}

At runtime UserId is just the int — there's no box, no allocation, no performance cost. But at compile time it's a separate type the analyzer enforces. This is perfect for:

  • Type-safe IDs / units (UserId, Meters, Celsius) that should never be confused with raw int/double.
  • Restricting an API surface — exposing only the operations you want from the underlying type.

Extension vs. extension type, one line: an extension adds methods to an existing type (same type, more methods); an extension type defines a brand-new compile-time type over an existing representation (new type, your chosen methods, zero runtime cost). They sound similar and are easy to mix up — but they solve different problems.


Practice Challenges

Challenge 1 — String helper. Write an extension on String adding a capitalized getter that uppercases the first letter.

Show solution
extension Cap on String {
  String get capitalized =>
      isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
}

void main() {
  print('hello'.capitalized); // Hello
  print(''.capitalized);      // (empty, no crash)
}

Inside the extension, this is the string, so isEmpty, [0], and substring all refer to it directly. Now every String reads .capitalized.

Challenge 2 — int helper. Add an extension on int with a times(void Function() action) method that runs the action that many times.

Show solution
extension Repeat on int {
  void times(void Function() action) {
    for (var i = 0; i < this; i++) {
      action();
    }
  }
}

void main() {
  3.times(() => print('hi')); // prints hi three times
}

3.times(...) reads almost like a built-in language loop. this is the integer receiver.

Challenge 3 — Generic list extension. Write a generic extension on List<T> with a T? get firstOrNull.

Show solution
extension FirstOrNull<T> on List<T> {
  T? get firstOrNull => isEmpty ? null : this[0];
}

void main() {
  print(<int>[].firstOrNull);   // null
  print([10, 20].firstOrNull);  // 10
}

The <T> makes the getter return the correct element type (T?) for any list, with full type safety. (Dart's standard library actually ships this in package:collection.)

Challenge 4 — Explain the failure. Why does this throw at runtime, and how would you make it work?

extension on String {
  String get shout => '${toUpperCase()}!';
}

void main() {
  dynamic msg = 'hello';
  print(msg.shout);
}
Show answer

Extensions are resolved on the static type, but msg is dynamic, which has no static type to resolve against — so shout isn't found at compile time and you get a runtime NoSuchMethodError.

Fixes: give it a static type (String msg = 'hello';), or call the extension explicitly. With a named extension you'd write MyExt('hello').shout; here the extension is unnamed, so the real fix is to type the variable as String.

Challenge 5 — Type-safe ID. Use an extension type to create an Email over String that can only be constructed from a value containing '@' (validate in a named constructor), exposing the raw value.

Show solution
extension type Email._(String value) {
  factory Email(String input) {
    if (!input.contains('@')) {
      throw ArgumentError('Invalid email: $input');
    }
    return Email._(input);
  }

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

void main() {
  final e = Email('sam@example.com');
  print(e.value);  // sam@example.com
  print(e.domain); // example.com
  // Email('nope'); // throws
}

The private Email._ constructor forces construction through the validating factory, so an Email is always a valid string — yet at runtime it's still just a String with zero wrapper overhead. (This mirrors the factory-validation pattern from Part 2.)

Challenge 6 — Distinct units. Define extension types Meters and Feet over double so the compiler refuses to mix them, and give Meters a toFeet() conversion.

Show solution
extension type Meters(double value) {
  Feet toFeet() => Feet(value * 3.28084);
}

extension type Feet(double value) {}

void distance(Meters m) => print('${m.value} m');

void main() {
  final d = Meters(100);
  distance(d);                 // 100.0 m
  // distance(Feet(50));       // ❌ compile error — Feet is not Meters
  print(d.toFeet().value);     // 328.084
}

Meters and Feet are both double at runtime but distinct types at compile time, so the analyzer stops you passing feet where meters are required — eliminating a whole class of unit-confusion bugs at zero runtime cost.


Questions to test yourself

Q1 (basic). What does an extension method let you do that you couldn't otherwise?

Show answer

Add methods, getters, setters, or operators to a type you don't own (like String, int, List, or a package class), so you can call value.myMethod() as though the type always had it — including in IDE autocomplete.

Q2 (basic). Inside an extension on String, what does this refer to?

Show answer

The specific String value the extension member was called on. Its members are also in scope directly, so you can call contains(...) instead of this.contains(...).

Q3 (intermediate). Why doesn't an extension method work when called on a dynamic variable?

Show answer

Extension methods are resolved at compile time based on the variable's static type. A dynamic variable has no static type for the compiler to match an extension against, so the member can't be resolved and the call fails at runtime with NoSuchMethodError. Give the variable a concrete static type (or use explicit extension-application syntax) to fix it.

Q4 (intermediate). If a type already has a real method named foo, and an in-scope extension also defines foo, which one runs? What does that imply?

Show answer

The type's real method runs — instance members always take precedence over extension members. The implication: extensions can only add new capabilities, never override or "monkey-patch" existing behavior. (To choose between two competing extensions, use explicit ExtensionName(value).foo syntax or show/hide/prefix on imports.)

Q5 (advanced). Contrast how a regular overridden method and an extension method are dispatched. Why does it matter for code correctness?

Show answer

A regular (overridden) method is dispatched dynamically on the object's runtime type — polymorphism, so a List<Animal> calling sound() runs each element's actual override. An extension method is dispatched statically on the variable's declared type, with no polymorphism. It matters because calling an extension through a supertype reference gives you the extension for that declared type, ignoring the object's real subtype — so extensions are not a substitute for real overrides when you need runtime-dispatched behavior.

Q6 (advanced). What is an extension type, how does it differ from an extension, and what's its key runtime property?

Show answer

An extension adds members to an existing type (the type stays the same, it just gains methods). An extension type defines a brand-new, distinct compile-time type that wraps an existing "representation" type, exposing only the API you choose. Its key property is being zero-cost: at runtime it is the underlying type (no wrapper object, no allocation), but at compile time the analyzer treats it as separate — perfect for type-safe IDs/units (UserId, Meters) that should never be interchangeable with the raw int/double.


Wrapping up

Extensions let you shape types you don't control:

  • Extension methods (extension ... on T) add methods/getters/operators to any type — built-in, package, or your own — with this as the extended value.
  • They're resolved statically, so they don't work on dynamic, aren't polymorphic, and never override real members (those always win).
  • Extensions can be generic (extension X<T> on List<T>), and conflicts are resolved with explicit Ext(value).member syntax or show/hide/prefixes.
  • Extension types are a separate feature: zero-cost, distinct compile-time wrappers over an existing representation — ideal for type-safe IDs and units.

In the final Part 10 we tie the whole series together with class modifiers and sealed classesabstract, base, interface, final, sealed — and use exhaustive pattern matching to write bulletproof, polymorphic code.