Skip to content
Blog

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 i for 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:

  1. The representation type itself — all its members become available on the extension type.
  2. A supertype of the representation type — for example extension type Words(List<String> words) implements Iterable<String>, exposing only the Iterable surface of a list.
  3. 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:

  • is checks, as casts, and pattern matching all see the representation type, never the extension type.
  • List<IdNumber> is indistinguishable from List<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, AccountNumber over String/int, so they cannot be mixed up at call sites.
  • Restricted views — a NonEmptyList-style type exposing only safe operations, or Celsius and Fahrenheit wrappers over double.
  • 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.

References