Dart Extension Types: Zero-Cost Wrappers for Safer APIs
Learn how Dart extension types give you branded types, restricted interfaces, and zero-cost interop wrappers — with syntax, transparency via implements, and when to prefer a real class.
Published on • September 25, 2026
AI Assistant

Stringly-typed APIs are a quiet source of production bugs. A function that takes a String userId will happily accept a String email address. A double temperature could be Celsius passed where Fahrenheit is expected. The usual fix — a wrapper class — adds safety but also allocates a real object on every wrap, which matters when you are wrapping millions of values or shuffling data across a JS interop boundary.
Dart 3.3 introduced extension types to close that gap. An extension type is a compile-time abstraction that wraps an existing type with a different, static-only interface. At runtime the wrapper vanishes entirely: there is no extra object, no allocation, no dispatch. You get the type safety of a wrapper at essentially zero cost.
The Core Idea
An extension type declaration introduces a new static type over an existing representation type:
extension type IdNumber(int id) {
operator <(IdNumber other) => id < other.id;
// No '+' operator: addition makes no sense for IDs.
}
void main() {
var safeId = IdNumber(42424242);
safeId + 10; // Compile-time error.
int raw = safeId; // Compile-time error: wrong static type.
safeId < IdNumber(42424241); // OK: comparison is defined.
}
IdNumber exposes exactly the operations you declare — nothing more. You cannot accidentally add one ID to another, and you cannot pass a bare int where an IdNumber is required. The compiler enforces the discipline; the runtime pays nothing for it.
Syntax and Members
The declaration extension type E(int i) { ... } introduces two things implicitly:
- A getter
int get ifor the representation value - An unnamed constructor
E(int i) : i = i
Inside the body you can declare methods, getters, setters, and operators. Instance variables and abstract members are not allowed, and members of the representation type are not inherited by default — if you want them, redeclare them:
extension type NumberE(int value) {
NumberE operator +(NumberE other) => NumberE(value + other.value);
NumberE get myNum => this;
bool isValid() => !value.isNegative;
}
Extension types support generics:
extension type E<T>(List<T> elements) {}
Constructors
Additional generative constructors must initialize the representation variable:
extension type E(int i) {
E.n(this.i);
E.m(int j, String foo) : i = j + foo.length;
}
Naming the representation constructor frees up the unnamed constructor name, and making it private (._) forces callers through your chosen constructors — a lightweight form of validation:
extension type E._(int i) {
E.fromString(String foo) : i = int.parse(foo);
E.positive(int value) : i = value, assert(value > 0);
}
Because the unnamed constructor is private, every E in your program was created through fromString or positive.
Transparency: The implements Clause
By default an extension type is opaque — a brand-new static type with no relationship to the representation type. Adding an implements clause makes it transparent, exposing the representation type’s members:
extension type NumberT(int value) implements int {
NumberT get i => this;
}
void main() {
var v3 = NumberT(1).i - NumberT(2); // int members available
int v4 = NumberT(2) + NumberT(1); // OK: assignable to int
}
The implements clause accepts three kinds of supertypes:
- The representation type itself — all its members become available on the extension type.
- A supertype of the representation type — for example
extension type Words(List<String> words) implements Iterable<String>, exposing only theIterablesurface of a list. - Another extension type on the same representation type — a way to layer and share sets of operations.
Opaque by default, transparent by choice: the type system only lets values cross boundaries you explicitly opened.
Redeclaring Members
A member that shadows a supertype member is a redeclaration, not an override — it completely replaces the supertype member for the extension type’s static interface. The @redeclare annotation (from package:meta) asks the analyzer to verify you actually matched the name:
extension type MyString(String _) implements String {
@redeclare
int operator [](int index) => codeUnitAt(index);
}
The annotate_redeclares lint flags any unannotated redeclaration, catching accidental misspellings that would otherwise silently add a new member instead of replacing one.
The Runtime Is Honest: Erasure
Extension types are erased at compilation. At runtime, only the representation type exists. The Dart documentation is explicit that this makes them an unsafe abstraction:
void main() {
var n = NumberE(1);
if (n is int) print(n); // Prints 1 — the wrapper is erased to int.
}
Consequences to internalize:
ischecks,ascasts, and pattern matching all see the representation type, never the extension type.List<IdNumber>is indistinguishable fromList<int>at runtime.- Casting to an extension type changes only the static type — no constructor runs. If you need validation, construct explicitly (
IdNumber(value)) rather than casting.
One subtle rule: the representation type is never a subtype of the extension type. A bare int cannot be passed where an IdNumber is expected, even though every IdNumber is an int underneath.
When to Use an Extension Type vs a Class
Reach for an extension type when you want an extended or restricted interface on an existing type without wrapper overhead:
- Branded identifiers —
UserId,OrderId,AccountNumberoverString/int, so they cannot be mixed up at call sites. - Restricted views — a
NonEmptyList-style type exposing only safe operations, orCelsiusandFahrenheitwrappers overdouble. - JS interop — reshaping an existing JS object’s interface without allocating a wrapper for every call. This was one of the primary motivations, and static interop packages rely heavily on it.
- Hot paths — wrapping millions of values per frame where a real wrapper class would measurably cost.
Prefer a real class when you need true encapsulation. A wrapper class can enforce invariants at construction and guarantee them forever; an extension type is, in the language team’s own words, just a compile-time view on the wrapped object. For anything where a malformed value must be impossible — not merely inconvenient — a class (or a class with final/base modifiers) remains the right tool.
Extension Types in Practice: A Small API Client
A pattern that scales nicely across a real codebase — brand your wire types and forbid stringly-typed calls:
extension type const Endpoint(String path) {
static const Endpoint users = Endpoint('/users');
static const Endpoint posts = Endpoint('/posts');
Endpoint operator +(String segment) => Endpoint('$path/$segment');
}
extension type ApiToken(String value) {
bool get isValid => value.length == 64;
}
Future<Map<String, Object?>> get(Endpoint endpoint, ApiToken token) {
// Only branded values can reach here.
return request('$endpoint', headers: {'Authorization': 'Bearer $token'});
}
Callers cannot pass a raw String path or an unvalidated token. The safety is static-only — a determined runtime cast can still smuggle values through — but in ordinary application code, static enforcement is what prevents the bug from being written in the first place.
Summary
Extension types add a missing rung to Dart’s type-system ladder: real type safety over existing types, with no runtime representation. Use them to brand primitives, restrict interfaces, and make interop boundaries cheap. Respect their one limitation — erasure means no runtime guarantees — and combine them with classes, sealed hierarchies, and the class modifier family for a type system that catches mistakes before your app ships.