Dart 30 🎯 Extension Types (extension type) for Zero-Cost Abstractions
A wrapper class is a useful thing. It gives a primitive a name, adds validation, and prevents an int from being passed where a UserId is expected. But a wrapper class has a cost: it allocates an object on the heap, it requires a field to hold the wrapped value, and every access goes through that field. For a type that is used in hot loops or in large collections, the cost adds up. Dart 3.3 introduced extension type, which provides the type safety of a wrapper without the runtime overhead. The wrapper is erased at runtime. The representation is the underlying value.
This chapter covers extension types in full. You will learn the declaration syntax, the representation type, the members that can be declared, and the difference between an extension type and a regular extension or a wrapper class. You will see the patterns where extension types replace wrapper classes—typed IDs, validated strings, and units of measure—and the rules that govern their runtime behavior. You will also see the situations where a wrapper class is still the right choice.
Key point: An extension type declares a new type whose runtime representation is another type. The new type is distinct at compile time, so the type system prevents mixing it with the representation type. At runtime, the wrapper is erased—there is no allocation and no indirection. The members declared on the extension type are resolved at compile time and compile to operations on the representation.
Why extension types exist
The wrapper overhead problem. A wrapper class like class UserId { final int value; UserId(this.value); } allocates an object for every value. In a list of a million user IDs, that is a million allocations. The garbage collector has to track them, and every access to value is a field read. An extension type eliminates both: extension type UserId(int value) is an int at runtime, and value is just the underlying integer.
The type-safety problem. An int can be a user ID, an order ID, a quantity, a port number, or an error code. The compiler cannot distinguish them, so it cannot prevent passing the wrong one. A wrapper class fixes this by making each one a distinct type, but at the cost of allocation. An extension type provides the same type distinction without the cost. void f(UserId id) cannot be called with a raw int, and UserId(42) is an int at runtime.
The protocol problem. Some types have a representation that is not the type itself but a related type. A duration can be represented as an int of milliseconds, or as a Duration object. A JSON value can be represented as a Map<String, dynamic>, or as a String of encoded JSON. An extension type declares the representation explicitly and adds members that operate on it. The representation is available as .value and can be accessed directly.
The mixin problem. Extension types can implement interfaces. This allows a type that is a thin wrapper over a representation to satisfy a contract. extension type UserId(int value) implements Comparable<UserId> declares that a UserId can be compared to another UserId, using the underlying int for the comparison.
The evolution problem. A public API that accepts int cannot be changed to accept a wrapper class without breaking every call site. An extension type changes the static type without changing the runtime representation. The API accepts UserId, and callers pass UserId(42). The change is a source-compatible breaking change—callers must wrap their values—but the runtime behavior is unchanged.
The limit of the abstraction. An extension type is a compile-time construct. It cannot add fields, and it cannot change the runtime type. If the type needs storage beyond the representation, or if it needs to be distinguished at runtime with is checks, an extension type is not sufficient. A wrapper class, a sealed class, or a regular class is the right choice for those cases.
a. Declaring extension types
An extension type is declared with the extension type keywords, a name, and a representation type in parentheses.
extension type UserId(int value) {
bool get isValid => value > 0;
}
The UserId is a new type. Its runtime representation is int. The value is the representation, accessible as a property. The isValid getter is a member of the extension type.
The type is distinct from int at compile time:
void printUserId(UserId id) {
print(id.value);
}
printUserId(UserId(42)); // OK
// printUserId(42); // Error: int is not UserId
The error occurs because int and UserId are different types. The compiler does not implicitly convert. The conversion is explicit: UserId(42) wraps, and id.value unwraps.
An extension type can have named constructors. The constructor must initialize the representation.
extension type EmailAddress(String value) {
EmailAddress.parse(String input) : value = input.trim().toLowerCase();
bool get isValid => value.contains('@') && value.contains('.');
}
The EmailAddress.parse constructor normalizes the input. The isValid getter checks the format. The representation is the normalized string.
An extension type can be generic:
extension type Wrapper<T>(T value) {
String get description => 'Wrapper of $value';
}
var w = Wrapper<int>(42);
print(w.description); // 'Wrapper of 42'
The representation is T, and the members can use it.
An extension type can declare methods, getters, setters, and operators:
extension type Meters(double value) {
Meters operator +(Meters other) => Meters(value + other.value);
Meters operator -(Meters other) => Meters(value - other.value);
double get inFeet => value * 3.28084;
bool get isPositive => value > 0;
}
var a = Meters(5);
var b = Meters(3);
print((a + b).value); // 8.0
print(a.inFeet); // 16.4042
The + and - operators are defined on the extension type. The inFeet getter converts. The isPositive getter checks the value.
An extension type can implement interfaces:
extension type UserId(int value) implements Comparable<UserId> {
@override
int compareTo(UserId other) => value.compareTo(other.value);
}
The UserId implements Comparable<UserId>. The compareTo method uses the underlying int. A list of UserId can be sorted using the standard sort method.
b. Extension types vs extensions vs wrapper classes
The three mechanisms are often confused because they share a name. They solve different problems.
A regular extension adds members to an existing type. It does not create a new type. extension StringHelpers on String adds members to String. Every String has those members when the extension is in scope. The type is unchanged.
An extension type creates a new type with a specified representation. The representation is accessible as .value. The new type is distinct from the representation at compile time and erased to it at runtime. The members are only available on the new type, not on the representation.
A wrapper class creates a new type with a field. The field is stored in the object. The wrapper is a separate allocation. The field is accessed through the object reference.
// Extension: adds to String
extension StringHelpers on String {
bool get isEmail => contains('@');
}
'x@y.com'.isEmail; // true
// Extension type: new type over String
extension type Email(String value) {
bool get isValid => value.contains('@');
}
Email('x@y.com').isValid; // true
// 'x@y.com'.isValid; // error: not a String member
// Wrapper class: new type with a field
class EmailWrapper {
final String value;
EmailWrapper(this.value);
bool get isValid => value.contains('@');
}
EmailWrapper('x@y.com').isValid; // true
The extension makes the member available on every string. The extension type makes it available only on the Email type. The wrapper class does the same but allocates an object.
The choice depends on what is needed:
| Need | Mechanism |
|---|---|
| Add methods to an existing type | Regular extension |
| Create a distinct type with no runtime cost | Extension type |
| Create a distinct type with additional storage | Wrapper class |
| Create a distinct type with runtime type checks | Wrapper class or sealed class |
An extension type cannot have a field of its own. The only storage is the representation. If the type needs to hold additional data, a wrapper class is necessary.
An extension type cannot be checked with is at runtime in the way a class can, because the representation is erased. value is UserId does not work if value is an int. The type exists only at compile time.
c. Patterns and limitations
Typed IDs. The most common use of extension types is to give primitives a domain-specific type.
extension type UserId(int value) {
bool get isValid => value > 0;
}
extension type OrderId(int value) {
bool get isValid => value > 0;
}
void processOrder(OrderId orderId, UserId userId) {
// Cannot accidentally swap the arguments
}
The processOrder function takes an OrderId and a UserId. Passing the arguments in the wrong order is a compile error, even though both are represented as int.
Validated strings. A string that must pass a validation rule can be wrapped in an extension type with a constructor that validates.
extension type NonEmptyString(String value) {
NonEmptyString.from(String input) : value = _validate(input);
static String _validate(String input) {
if (input.isEmpty) throw ArgumentError('Cannot be empty');
return input;
}
}
The from constructor validates and returns the wrapped value. The value property gives the underlying string.
Units of measure. Physical quantities can be represented as numbers with a unit-specific type.
extension type Meters(double value) {
double get inFeet => value * 3.28084;
}
extension type Kilograms(double value) {
double get inPounds => value * 2.20462;
}
A function that takes Meters cannot be passed a Kilograms, even though both are double at runtime.
JSON wrappers. A type that represents encoded JSON can be represented as a String.
extension type JsonString(String value) {
Map<String, dynamic> decode() => jsonDecode(value) as Map<String, dynamic>;
}
The extension type adds a decode method to the string representation. The string is still a string, and the method is available only on the JsonString type.
Limitations. An extension type cannot:
- Declare a field other than the representation.
- Be used with
isorasto check the runtime type. - Have a constructor that does not initialize the representation.
- Extend a class. It can only implement interfaces.
- Be used as a mixin.
An extension type can:
- Declare getters, setters, methods, and operators.
- Implement interfaces.
- Have named constructors.
- Be generic.
- Be used as a parameter or return type.
- Be used as a type argument.
The representation is always available as .value. The type is erased at runtime. The members are resolved at compile time and compile to operations on the representation.
A comparison with sealed classes: a sealed class hierarchy is used when the alternatives are different types, each with its own data and behavior. An extension type is used when the alternative is a single representation with additional type safety. The two are not substitutes.
Complete Example Session
// ============================================
// PART 1: BASIC EXTENSION TYPE
// ============================================
extension type UserId(int value) {
bool get isValid => value > 0;
}
void main() {
var id = UserId(42);
print(id.value); // 42
print(id.isValid); // true
}
// ============================================
// PART 2: TYPE SAFETY
// ============================================
void printUserId(UserId id) => print(id.value);
printUserId(UserId(42)); // OK
// printUserId(42); // Error
// ============================================
// PART 3: NAMED CONSTRUCTOR
// ============================================
extension type EmailAddress(String value) {
EmailAddress.parse(String input) : value = input.trim().toLowerCase();
bool get isValid => value.contains('@') && value.contains('.');
}
var email = EmailAddress.parse(' Alice@Example.COM ');
print(email.value); // 'alice@example.com'
print(email.isValid); // true
// ============================================
// PART 4: GENERIC EXTENSION TYPE
// ============================================
extension type Wrapper<T>(T value) {
String get description => 'Wrapper of $value';
}
print(Wrapper<int>(42).description); // 'Wrapper of 42'
// ============================================
// PART 5: METHODS AND OPERATORS
// ============================================
extension type Meters(double value) {
Meters operator +(Meters other) => Meters(value + other.value);
Meters operator -(Meters other) => Meters(value - other.value);
double get inFeet => value * 3.28084;
}
var total = Meters(5) + Meters(3);
print(total.value); // 8.0
print(total.inFeet); // 26.2467
// ============================================
// PART 6: IMPLEMENTING INTERFACES
// ============================================
extension type UserId(int value) implements Comparable<UserId> {
@override
int compareTo(UserId other) => value.compareTo(other.value);
}
var ids = [UserId(3), UserId(1), UserId(2)];
ids.sort();
print(ids.map((id) => id.value).toList()); // [1, 2, 3]
// ============================================
// PART 7: TYPED IDS FOR SAFETY
// ============================================
extension type OrderId(int value) {
bool get isValid => value > 0;
}
void processOrder(OrderId orderId, UserId userId) {
print('Order ${orderId.value} for user ${userId.value}');
}
processOrder(OrderId(1), UserId(42)); // OK
// processOrder(UserId(42), OrderId(1)); // Error: swapped
// ============================================
// PART 8: VALIDATED STRING
// ============================================
extension type NonEmptyString(String value) {
NonEmptyString.from(String input) : value = _validate(input);
static String _validate(String input) {
if (input.isEmpty) throw ArgumentError('Cannot be empty');
return input;
}
}
var name = NonEmptyString.from('Alice');
print(name.value); // 'Alice'
// var bad = NonEmptyString.from(''); // throws
// ============================================
// PART 9: UNITS OF MEASURE
// ============================================
extension type Kilograms(double value) {
double get inPounds => value * 2.20462;
}
var weight = Kilograms(70);
print(weight.inPounds); // 154.3234
// ============================================
// PART 10: JSON WRAPPER
// ============================================
import 'dart:convert';
extension type JsonString(String value) {
Map<String, dynamic> decode() =>
jsonDecode(value) as Map<String, dynamic>;
}
var json = JsonString('{"name":"Alice"}');
print(json.decode()['name']); // 'Alice'
The ten parts covered basic extension types, type safety, named constructors, generic extension types, methods and operators, implementing interfaces, typed IDs, validated strings, units of measure, and JSON wrappers.
Quick Reference
Extension Type Syntax
| Part | Syntax |
|---|---|
| Declaration | extension type Name(Type value) { } |
| Representation | value property |
| Named constructor | Name.ctor(args) : value = ...; |
| Members | Getters, setters, methods, operators |
| Interfaces | implements Interface |
| Generics | extension type Name<T>(T value) |
Extension Type Capabilities
| Capability | Supported |
|---|---|
| Add getters | Yes |
| Add setters | Yes |
| Add methods | Yes |
| Add operators | Yes |
| Add fields | No |
| Implement interfaces | Yes |
| Extend classes | No |
| Runtime type checks | No |
| Named constructors | Yes |
| Generic parameters | Yes |
Extension vs Extension Type vs Wrapper
| Aspect | Extension | Extension Type | Wrapper Class |
|---|---|---|---|
| Creates new type | No | Yes | Yes |
| Runtime cost | None | None | Allocation |
| Storage | None | Representation only | Fields |
| Runtime checks | No | No | Yes |
| Use case | Add methods | Type safety | Full abstraction |
Common Patterns
| Pattern | Example |
|---|---|
| Typed ID | extension type UserId(int value) |
| Validated string | extension type Email(String value) |
| Unit of measure | extension type Meters(double value) |
| JSON wrapper | extension type JsonString(String value) |
| Generic wrapper | extension type Wrapper<T>(T value) |
Best Practices
✅ Do This:
// Use extension types for typed primitives
extension type UserId(int value) { } // ✅
// Use named constructors for validation
extension type Email(String value) {
Email.parse(String input) : value = input.trim().toLowerCase();
} // ✅
// Implement interfaces for sorting and comparison
extension type UserId(int value) implements Comparable<UserId> { } // ✅
// Use extension types for units of measure
extension type Meters(double value) { } // ✅
// Access the representation with .value
print(userId.value); // ✅
// Use extension types for zero-cost type safety
void process(OrderId id, UserId user) { } // ✅
❌ Don’t Do This:
// Don't try to add fields to an extension type
extension type Bad(int value) {
int extra = 0; // error // ❌
}
// Don't use is checks on extension types
if (value is UserId) { } // does not work at runtime // ❌
// Don't extend a class with an extension type
extension type Bad(int value) extends Base { } // ❌
// Don't use an extension type when you need runtime type checks
// Use a wrapper class or sealed class // ⚠️
// Don't confuse extension type with extension
extension type Name(String value) { } // new type // ✅
extension Name on String { } // adds to String // ✅
// Don't use extension types for complex abstraction
// Use a wrapper class when storage is needed // ⚠️
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Cannot add fields | Extension types have no storage | Use a wrapper class |
is check fails | Type is erased at runtime | Use a wrapper class |
| Cannot extend class | Extension types only implement | Use a regular class |
| Constructor not initializing | Must set value | Initialize the representation |
| Confusion with extension | Similar names | Extension types create new types |
| Representation not accessible | Forgot .value | Access the value property |
| Interface not implemented | Missing implements | Add the interface |
Real-World Examples
1. User ID
extension type UserId(int value) {
bool get isValid => value > 0;
}
2. Order ID
extension type OrderId(int value) {
bool get isValid => value > 0;
}
3. Email Address
extension type EmailAddress(String value) {
EmailAddress.parse(String input) : value = input.trim().toLowerCase();
bool get isValid => value.contains('@');
}
4. Non-Empty String
extension type NonEmptyString(String value) {
NonEmptyString.from(String input) : value = _validate(input);
static String _validate(String s) =>
s.isEmpty ? throw ArgumentError() : s;
}
5. Meters
extension type Meters(double value) {
double get inFeet => value * 3.28084;
}
6. Kilograms
extension type Kilograms(double value) {
double get inPounds => value * 2.20462;
}
7. Percentage
extension type Percentage(double value) {
double get asFraction => value / 100;
}
8. JSON String
extension type JsonString(String value) {
Map<String, dynamic> decode() =>
jsonDecode(value) as Map<String, dynamic>;
}
9. Positive Int
extension type PositiveInt(int value) {
PositiveInt.from(int input) : value = _validate(input);
static int _validate(int n) =>
n <= 0 ? throw ArgumentError('Must be positive') : n;
}
10. Sortable ID
extension type SortableId(int value) implements Comparable<SortableId> {
@override
int compareTo(SortableId other) => value.compareTo(other.value);
}
Visual
Extension Type Erasure
┌─────────────────────────────────────────────────────────────┐
│ COMPILE TIME │
│ │
│ extension type UserId(int value) { } │
│ │
│ UserId is a distinct type. │
│ void f(UserId id) { } │
│ │
│ f(UserId(42)); ✓ │
│ f(42); ✗ error: int is not UserId │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ RUNTIME │
│ │
│ UserId(42) is represented as the int 42. │
│ │
│ No wrapper object. │
│ No field. │
│ No allocation. │
│ │
│ The type exists only at compile time. │
│ │
└─────────────────────────────────────────────────────────────┘
Three Mechanisms Compared
┌─────────────────────────────────────────────────────────────┐
│ EXTENSION │
│ │
│ Adds members to an existing type. │
│ String gets isEmail from StringHelpers. │
│ Every String has it when in scope. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ EXTENSION TYPE │
│ │
│ Creates a new type over a representation. │
│ Email is a new type over String. │
│ Only Email has isValid. │
│ Runtime representation is String. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WRAPPER CLASS │
│ │
│ Creates a new type with a field. │
│ EmailWrapper has a final String value field. │
│ Allocates an object. │
│ Runtime type is EmailWrapper. │
│ │
└─────────────────────────────────────────────────────────────┘
Type Safety Example
┌─────────────────────────────────────────────────────────────┐
│ WITHOUT EXTENSION TYPES │
│ │
│ void process(int orderId, int userId) { } │
│ process(42, 1); // OK │
│ process(1, 42); // OK but wrong │
│ │
│ The compiler cannot catch the swap. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WITH EXTENSION TYPES │
│ │
│ extension type OrderId(int value) { } │
│ extension type UserId(int value) { } │
│ │
│ void process(OrderId orderId, UserId userId) { } │
│ process(OrderId(42), UserId(1)); // OK │
│ process(UserId(1), OrderId(42)); // Error │
│ │
│ The compiler catches the swap. │
│ │
└─────────────────────────────────────────────────────────────┘
When to Use Each
┌─────────────────────────────────────────────────────────────┐
│ Do you need a new type? │
│ │ │
│ ├── NO ──▶ Regular extension │
│ │ (add methods to existing type) │
│ │ │
│ └── YES │
│ │ │
│ ▼ │
│ Do you need additional storage? │
│ │ │
│ ├── YES ──▶ Wrapper class │
│ │ │
│ └── NO │
│ │ │
│ ▼ │
│ Do you need runtime type checks? │
│ │ │
│ ├── YES ──▶ Wrapper class or sealed class │
│ │ │
│ └── NO ──▶ Extension type │
│ (zero-cost type safety) │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Declaration | extension type Name(Type value) { } |
| Representation | Accessed as value |
| Runtime | Erased to representation |
| Allocation | None |
| Members | Getters, setters, methods, operators |
| Fields | Not allowed |
| Interfaces | Can implement |
| Inheritance | Cannot extend a class |
Runtime is | Does not work |
| Named constructors | Yes |
| Generics | Yes |
Key takeaways:
- An extension type creates a new type with a specified representation. The type is distinct from the representation at compile time and erased to it at runtime. There is no allocation and no indirection.
- The representation is accessed through the
valueproperty. The property is always available, and it is the only storage the extension type has. Additional fields are not allowed. - The type safety is real at compile time.
void f(UserId id)cannot be called with a rawint. The compiler enforces the distinction, which prevents passing the wrong primitive where a specific type is expected. - The runtime cost is zero. An extension type is a compile-time construct. At runtime, the value is the representation. There is no wrapper object to allocate or garbage collect.
- Runtime type checks do not work.
value is UserIddoes not compile to a meaningful check because the type is erased. If runtime distinction is needed, a wrapper class or sealed class is the right choice. - Named constructors can validate and normalize.
EmailAddress.parsecan trim and lowercase the input before storing it in the representation. The constructor is the place for validation logic. - Interfaces can be implemented. An extension type can implement
Comparable,Iterable, or any other interface. The implementation uses the representation to satisfy the contract. - Extension types, regular extensions, and wrapper classes solve different problems. Use a regular extension to add methods to an existing type. Use an extension type to create a distinct type with no runtime cost. Use a wrapper class when storage or runtime type checks are needed.
Remember: An extension type is a compile-time wrapper. It gives a primitive a name, adds validation, and prevents the wrong value from being passed, all without allocating an object. The representation is the only storage, and it is available as value. The type is erased at runtime, so runtime type checks and additional fields are not possible. Use extension types for typed IDs, validated strings, units of measure, and JSON wrappers—the cases where the type safety is worth more than the storage. Use a wrapper class when you need to hold additional data or check the type at runtime. The two mechanisms are complementary, and choosing between them is a question of what the type needs to do at runtime.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!