Dart Expando: Attaching Hidden Metadata to Objects Without Memory Leaks
Every once in a while, Dart gives you a feature that looks tiny on the surface but opens a door into how the language thinks about objects, identity, and memory.
Expando is one of those features. It’s not new, it’s been sitting in dart:core since the early SDK days, and it’s also one of the most ignored corners of the language. For a good reason, too: most app code will never need it.
In this article, we’ll look at what Expando is, how it works, where it earns its keep, and why you’ll probably go months without reaching for it, until the one day you really need it.
What is an Expando?
Here’s what the official Dart docs say:
An Expando allows adding new properties to objects. An Expando does not hold on to the added property value after an object becomes inaccessible.
Sounds interesting and confusing at the same time, right? But it’s very simple. It does 2 things:
- It lets you attach a property to an object from the outside. The object has no idea this property exists.
- It does this without keeping the object alive. If nothing else references the object, the garbage collector can still take it. When that happens, the stored value also get garbage collected if nothing else is holding it.
[Hidden/External properties] + [weak reference] + [automatic cleanup] = Expando
It’s that simple. Here’s an example:
final notes = Expando<String>('notes'); // Optional name of the expando.
final object = Object();
notes[object] = 'Some metadata';
print(notes[object]); // Some metadata
Think of it as a hidden side table that lives next to the object, not inside it:
object ──► original object
│
└──► Expando side data: "Some metadata"
The object itself never finds out. Its class isn’t changed, no field was added, nothing about it looks any different from the inside. The data lives entirely on Expando’s side.
The official Dart docs describe Expando as a way to add new properties to objects. They also note an important memory behavior: an Expando does not hold on to the added property value after the object becomes inaccessible.
Now the part that actually makes Expando worth knowing: An Expando does not hold on to the added property value after the object becomes inaccessible. Plenty of things in Dart can attach a value to a key. Only Expando promises to let go when you do.
Why not just use a Map?
Fair question. A Map<Object, T> can do everything the code above just did:
final notes = <Object, String>{};
final object = Object();
notes[object] = 'Some metadata';
And it works, right up until it doesn’t. The problem isn’t the syntax, it’s what a Map does to its keys: it holds them. Strongly. For as long as the map itself is alive.
Picture this innocent-looking function:
final cache = <Object, String>{};
void doWork() {
final object = Object();
cache[object] = 'metadata';
}
doWork() returns. As far as your code is concerned, object is gone, out of scope, forgotten. But the map doesn’t know that. It’s still holding a reference to object as a key, so the garbage collector can’t touch it. You’ve built a leak, one object at a time, and nothing in the code looks wrong.
cache
└── key: object ──► keeps object alive
Expando flips that relationship. The association it creates is weak with respect to the key. If nothing else in your program holds onto object, Expando won’t be the reason it survives:
final notes = Expando<String>('notes');
void doWork() {
final object = Object();
notes[object] = 'metadata';
}
doWork() returns, object has no other references, and it’s eligible for garbage collection, Expando entry won’t hold it back and will actually participate by allowing the value property to be garbage collected too.
This is the whole promise: the side data never outlives the object it is attached to.
Basic usage
Enough theory. Here’s Expando doing something mundane: handing out a debug ID.
final debugIds = Expando<int>('debug ids');
int _nextId = 0;
int debugIdFor(Object object) {
return debugIds[object] ??= _nextId++;
}
class UserSession {}
void main() {
final session = UserSession();
print(debugIdFor(session)); // 0
print(debugIdFor(session)); // 0, same object gets same ID
final anotherSession = UserSession();
print(debugIdFor(anotherSession)); // 1
}
UserSession never grew an id field. The ID is bolted on from the outside, and only debugIdFor knows it exists.
Handy in logs, too:
void logSession(UserSession session, String message) {
print('[session:${debugIdFor(session)}] $message');
}
This is excellent as a debug id will disappear as soon as a session object for it is garbage collected and the side table of ids using expando won’t hold it back from being garbage collected.
Use case 1: Add state behind extension methods
This is where expando shines! Dart’s extension methods are great at adding behavior to a type you don’t own: methods, getters, operators. What they can’t do is add a stored field; extensions never get to touch the class’s memory layout. Expando is how you work around that.
Say you want a debugLabel on ScrollController, without subclassing it.
import 'package:flutter/widgets.dart';
extension ScrollControllerDebugLabel on ScrollController {
static final _labels = Expando<String>('scroll controller debug labels');
String? get debugLabel => _labels[this];
set debugLabel(String? value) {
_labels[this] = value;
}
}
Now it reads like it was always there:
final controller = ScrollController();
controller.debugLabel = 'Home feed scroll';
print(controller.debugLabel); // Home feed scroll
This is the cleanest use of Expando I know: faking a field through an extension, when the real class is sealed off from you.
You don’t need to manually remove entries from _labels when a controller is no longer needed. It happens automatically preventing memory leaks not holding back objects that should’ve been garbage collected.
Use case 2: Cache expensive derived data
Suppose you’re walking a tree, parser output, an analyzer’s AST, a widget tree, anything you don’t own the class for. Re-deriving the same information for the same node twice is wasted work, so you want to cache it. The question is where.
final resolvedTypes = Expando<String>('resolved types');
class AstNode {
final String source;
AstNode(this.source);
}
String resolveType(AstNode node) {
final cached = resolvedTypes[node];
if (cached != null) return cached;
final result = _expensiveTypeResolution(node);
resolvedTypes[node] = result;
return result;
}
String _expensiveTypeResolution(AstNode node) {
// Pretend this is expensive with some traversal or recursion.
return node.source.contains('"') ? 'String' : 'Object';
}
A Map<AstNode, String> would work, except it keeps every node you’ve ever seen pinned in memory for as long as the map exists, even nodes from a file the user closed an hour ago. Expando lets the cache entries disappear on their own, exactly when the nodes they describe become garbage.
parse source
→ build AST nodes
→ attach analysis metadata
→ let metadata die with nodes
Use case 3: Track initialization or lifecycle metadata
Sometimes the question isn’t “what data does this object have,” it’s “have I already touched this object before.”
final initialized = Expando<bool>('initialized');
void ensureInitialized(Object object) {
if (initialized[object] == true) return;
// Perform setup once.
initialized[object] = true;
}
This is the kind of bookkeeping library and framework code does constantly, and it’s exactly where Expando belongs: you don’t control the object’s class, but you still need somewhere to leave yourself a note.
Use case 4: Attach debug information without polluting production models
Take an ordinary model:
class User {
final String name;
User(this.name);
}
You’re not going to add a sourceLocation field to User just so your debug tooling has somewhere to put a line number. That field would ship to production and sit there empty for every user who isn’t you. Expando gives the tooling its own place to write, off to the side:
final sourceLocations = Expando<String>('source locations');
void attachSourceLocation(User user, String location) {
sourceLocations[user] = location;
}
String? sourceLocationOf(User user) {
return sourceLocations[user];
}
User stays exactly as clean as it should be. The debug layer gets what it needs without anyone agreeing to carry it.
Some Limitations
Before you go attaching shadows to everything in sight: Expando only works on identity-bearing objects.
It does not work on:
- numbers
- strings
- booleans
- records
nulldart:ffipointers, structs, or unions
Here’s what the docs says:
Since you can always create a new number that is identical to an existing number, it means that an expando property on a number could never be released. To avoid this, expando properties cannot be added to numbers. The same argument applies to Strings, booleans and
null, which also have literals that evaluate to identical values when they occur more than once. In addition, expando properties can not be added to records because records do not have a well-defined persistent identity.
This means, you can’t use these types of objects as keys. You can still use them as values.
It all comes back to identity. Expando attaches data to one specific object instance, the same instance, in memory, every time. Numbers, strings, booleans, null, and records don’t have that kind of single, addressable instance to hang a value off of. They’re canonical or value-like by design. There’s no “the” instance of 42 for Expando to point at.
This works:
final expando = Expando<String>();
final object = Object();
expando[object] = 'metadata';
This does not:
final expando = Expando<String>();
expando[42] = 'metadata'; // Invalid
expando['hello'] = 'metadata'; // Invalid
expando[true] = 'metadata'; // Invalid
expando[(1, 2)] = 'metadata'; // Invalid
So remember, only use identity-bearing objects. There is no restriction on other classes, even for compile time constant objects.
Be careful if adding expando properties to compile time constants, since they will stay alive forever!
Expando is identity-based, not value-based
Worth saying plainly, since it trips people up.
final expando = Expando<String>();
class Point {
final int x;
final int y;
Point(this.x, this.y);
}
void main() {
final a = Point(1, 2);
final b = Point(1, 2);
expando[a] = 'first';
print(expando[a]); // first
print(expando[b]); // null
}
a and b hold identical data. They are not the same object, and Expando only ever cares about the object:
a ──► Point(1, 2) object A
b ──► Point(1, 2) object B
Even if
Pointoverrode==to makea == btrue,Expandowouldn’t care. It was never asking that question.
Removing an Expando value
Although, not needed to be done manually but if you want, set it to null.
final labels = Expando<String>('labels');
final object = Object();
labels[object] = 'temporary label';
print(labels[object]); // temporary label
labels[object] = null;
print(labels[object]); // null
Expando<T> always hands back a T?. null just means nobody’s attached anything here, at least not right now.
Multiple Expandos are independent
The string you pass to Expando’s constructor is a label for debuggers, nothing more. Two Expandos with the same name still keep two completely separate side tables:
final a = Expando<String>('label');
final b = Expando<String>('label');
final object = Object();
a[object] = 'from a';
b[object] = 'from b';
print(a[object]); // from a
print(b[object]); // from b
Same name, same key, no relationship whatsoever between a and b.
Expando vs Map
Side by side, the trade-off is easy to see:
| Feature | Map<Object, T> | Expando<T> |
|---|---|---|
| Keeps keys alive | Yes | No |
| Works with strings/numbers/bools | Yes | No |
| Key lookup | Equality/hash-based | Identity/object-associated |
| Good for normal data storage | Yes | Rarely |
| Good for hidden object metadata | Sometimes | Yes |
| Can iterate entries | Yes | No public iteration API |
| Can remove manually | Yes | Set value to null |
The missing iteration API isn’t an oversight. Expando was never meant to be a map you can inspect. It’s side storage, on purpose, with no API for looking at everything it’s holding.
Expando vs adding a field
If you own the class, this isn’t even a contest. A real field wins.
Prefer this:
class UserSession {
final String debugLabel;
UserSession(this.debugLabel);
}
Over this:
final labels = Expando<String>();
class UserSession {}
Expando earns its place only when adding a field isn’t an option:
- the class comes from another package
- the metadata is optional or debug-only
- the metadata belongs to a framework/tooling layer
- you need the metadata to disappear with the object
- you are adding state behind an extension API
Expando and Flutter
In the Flutter app code you write day to day, you can probably forget Expando exists. Your state belongs in:
- widgets
Stateobjects- controllers
- providers/notifiers/blocs
- models
- services
Where it earns a spot is one layer down, in library and tooling code.
import 'package:flutter/widgets.dart';
final controllerLabels = Expando<String>('controller labels');
void labelController(ScrollController controller, String label) {
controllerLabels[controller] = label;
}
String describeController(ScrollController controller) {
return controllerLabels[controller] ?? 'unlabeled controller';
}
The debug metadata never touches ScrollController itself.
The same goes for anyone writing an analyzer, a router, a form framework, a serializer. Expando lets you bolt on internal state without asking every user of your package to extend a base class or mix in your state.
Where Expando can bite you
1. Hidden state can surprise people
This line:
labels[object] = 'special';
attaches a value nobody can see by reading the object’s class. That’s the entire point, and it’s also the danger: behavior that depends on invisible state is behavior nobody can trace just by reading the type. Keep Expando in infrastructure code, not in everyday domain logic.
2. It only works with identity
If your mental model is “objects that are equal should share this data,” Expando will quietly disagree with you every time.
Point(1, 2)andPoint(1, 2)should share metadata
If that’s what you want, Expando is the wrong tool. Reach for a Map keyed on a value object with a real == and hashCode instead.
3. It is not iterable
There’s no way to ask an Expando what it’s got. If you ever need to list everything it’s tracking, you’ve outgrown Expando. Switch to a Map, or keep a parallel structure.
4. It is not a replacement for architecture
And if Expando starts showing up all over your app, that’s not an Expando problem. It’s usually a sign that nobody decided who owns this state, and Expando became the path of least resistance instead of an answer.
A practical pattern: debug-only metadata
The trick that keeps all of this manageable is to never call Expando directly from call sites. Hide it behind a small, purpose-built API:
class DebugLabels {
static final Expando<String> _labels = Expando<String>('debug labels');
static void set(Object object, String label) {
_labels[object] = label;
}
static String? get(Object object) {
return _labels[object];
}
static void clear(Object object) {
_labels[object] = null;
}
}
Usage:
final controller = Object();
DebugLabels.set(controller, 'main feed controller');
print(DebugLabels.get(controller));
DebugLabels.clear(controller);
Now the only place that knows Expando is involved is this one file. Everywhere else just sees DebugLabels.set and DebugLabels.get.
When should you use Expando?
Reach for Expando only when every one of these is true:
- You need to associate data with a specific object instance.
- You do not own the object’s class, or you do not want to modify it.
- The associated data should not keep the object alive.
- You do not need to iterate over all associations.
- Identity, not value equality, is the correct relationship.
If even one of those is false, what you actually want is a normal field, a Map, or a clearer answer to who owns this state.
What this article didn’t cover
In the interest of being honest about scope, a few things got left out on purpose:
- How
Expandois actually implemented inside the Dart VM, or how its weak lookup compares in cost to a regularHashMaplookup. WeakReferenceandFinalizerfromdart:core, which solve adjacent but different problems and deserve their own article.- Whether every backend (VM, dart2js, dart2wasm) behaves identically. It’s specified to, but I haven’t gone and verified that myself across all three.
None of that changes when you’d reach for Expando. It just means there’s more to know if you go looking.
Summary
Expando is a shadow you can attach to any object: invisible from inside the object, and gone the instant the object is.
object ──► normal object
│
└──► expando metadata
It earns its keep in libraries, tools, frameworks, and debug systems, anywhere you need to attach metadata to an object from the outside, without that metadata outliving the object or leaking into its public API.
But it’s deliberately small. Not a general-purpose map. Not a state-management primitive. Not something most app code should reach for on a Tuesday.
Use
Expandowhen the metadata belongs to the object’s lifetime, but has no business being part of its public API.
That’s the whole niche. It’s narrow on purpose, and that’s exactly why it works.
Hope you enjoyed learning about Expando as much as I did!