Skip to main content

Smarter Futures in Dart with Zero-Cost Extensions

· 3 min read
Sinan Aktepe
Software Engineer

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.