Smarter Futures in Dart with Zero-Cost Extensions
Async code often comes with small patterns we repeat everywhere: wrapping a Future in try-catch, preventing loading indicators from flickering, or collecting only successful results from multiple requests.
We could solve this with a wrapper class, but that would introduce another runtime object around every Future.
Dart's extensions give us a better fit here. They let us expose a focused API directly on an existing type while being zero-cost at runtime. Extension methods are resolved statically — no wrapper object needs to exist at runtime.
So we can turn this:
Future<User>
into a richer API:
fetchUser().guard();
fetchUser().withMinDelay();
without changing how the underlying Future is represented.
extension SmartFuture<T> on Future<T> {
Future<(T? data, Object? error)> guard() async {
try {
return (await this, null);
} catch (e) {
return (null, e);
}
}
Future<T> withMinDelay([
Duration delay = const Duration(milliseconds: 500),
]) async {
final results = await Future.wait([
this,
Future.delayed(delay),
]);
return results.first as T;
}
Future<(T? data, Object? error)> guardWithDelay([
Duration delay = const Duration(milliseconds: 500),
]) {
return withMinDelay(delay).guard();
}
}
Guarding a Future
Instead of writing try-catch around every async call:
final (:data, :error) = await fetchUser().guard();
if (error != null) {
// Handle error
return;
}
print(data);
guard() turns success and failure into a Dart record:
(T? data, Object? error)
No exception escapes the call, and the result can be destructured naturally.
Preventing Loading Flicker
Fast requests can make loaders or skeletons appear for only a few milliseconds, creating an unpleasant flicker.
final user = await fetchUser().withMinDelay();
The operation and delay run concurrently, so the total duration is effectively:
max(operationDuration, minimumDelay)
A 100ms request with a 500ms minimum delay takes 500ms. A 2-second request still takes roughly 2 seconds — not 2.5.
When both behaviors are needed:
final (:data, :error) = await fetchUser().guardWithDelay();
guardWithDelay() simply composes the existing helpers instead of duplicating their logic.
Working with Multiple Futures
For collections of futures, a separate extension keeps the API focused:
extension SmartFutureGroup<T> on Iterable<Future<T>> {
Future<List<T>> waitAll() => Future.wait(this);
Future<List<T>> waitOnlySuccessful() async {
final results = <T>[];
for (final future in this) {
try {
results.add(await future);
} catch (_) {
// Skip failed operations
}
}
return results;
}
}
Now we can choose between all-or-nothing:
final users = await [
fetchUser(1),
fetchUser(2),
fetchUser(3),
].waitAll();
Or partial success:
final users = await [
fetchUser(1),
fetchUser(2),
fetchUser(3),
].waitOnlySuccessful();
The second version keeps successful results and ignores failed operations.
The Point
None of these helpers do anything magical. The interesting part is the abstraction.
Extensions let us build a dedicated API directly over existing Dart types without introducing a traditional runtime wrapper. We keep the representation of Future<T>, while gaining a more expressive interface for the async patterns we use repeatedly:
future.guard();
future.withMinDelay();
future.guardWithDelay();
futures.waitAll();
futures.waitOnlySuccessful();
A small, zero-cost abstraction that removes async boilerplate and makes intent explicit.
