skip to content

In Dart, how do you turn a callback-based API into a Future with a Completer, and what goes wrong if you complete it incorrectly?

level: middleimportance: should knowfreq 40%

answer

  1. hand out completer.future
  2. complete or completeError, once
  3. second completion throws StateError
  4. forget both and it hangs
  5. Completer.sync only in tail position

basics

~10 s

Create a Completer<T>, return completer.future, and call complete(value) or completeError(error, stackTrace) exactly once from the callbacks. Completing twice throws StateError; never completing leaves every awaiter hanging.

solid answer

~40 s

A `Completer<T>` from `dart:async` gives you a `future` to hand out and two methods to settle it: `complete([FutureOr<T>? value])` and `completeError(error, [stackTrace])`. To adapt a callback API, create the completer, start the operation, call `complete` from the success callback and `completeError` from the failure callback, and return `completer.future`. Rules: complete **at most once** — a second call throws `StateError('Future already completed')`, so guard racing callbacks with `isCompleted`; make sure **every** path completes, or awaiters hang forever; pass the `StackTrace` to `completeError`; and if nothing is listening when `completeError` runs, it is reported as an uncaught error in the zone. The default completer notifies listeners in a later microtask; `Completer.sync()` notifies immediately and is only safe as the last action of an already-asynchronous callback.

code

dart · 23 lines
dart
import 'dart:async';

abstract interface class LegacyUploader {
  void upload(
    String path, {
    required void Function(String url) onDone,
    required void Function(Object error, StackTrace stackTrace) onError,
  });
}

Future<String> uploadFile(LegacyUploader uploader, String path) {
  final completer = Completer<String>();
  uploader.upload(
    path,
    onDone: (url) {
      if (!completer.isCompleted) completer.complete(url);
    },
    onError: (error, stackTrace) {
      if (!completer.isCompleted) completer.completeError(error, stackTrace);
    },
  );
  return completer.future;
}

go deeper

for a junior

Recall the three parts: future to return, complete for success, completeError for failure.

for a middle

Explain the once-only rule and its StateError, the hang when a path never completes, forwarding the stack trace, and asynchronous notification.

for a senior

Harden adapters around racing or cancellable callbacks with isCompleted guards and explicit completeError on cancel, and prefer async functions wherever a completer is not needed.

for a principal

Keep completers at the edges where callback APIs enter the codebase, so the rest of the system is plain async code whose errors and completion are guaranteed by the language.

## What a Completer is Most Dart code creates futures implicitly, by writing `async` functions. When the result arrives through a **callback** instead — a legacy SDK, a native plugin's listener, an event that fires once — you need a future you can settle by hand. That is **`Completer<T>`**: - `completer.future` — the `Future<T>` you return to callers; - `completer.complete([FutureOr<T>? value])` — succeeds the future (if `value` is itself a future, the completer follows it); - `completer.completeError(Object error, [StackTrace? stackTrace])` — fails it; - `completer.isCompleted` — `true` once either method has been called. ## The adapter pattern 1. Create `final completer = Completer<T>();`. 2. Start the callback-based operation, passing callbacks that call `complete` or `completeError`. 3. Return `completer.future` synchronously. In a server job, for example, wrapping a legacy uploader whose API is `upload(path, onDone: ..., onError: ...)` lets the job simply `await uploadFile(path)` inside a `try`/`catch`. ## What goes wrong | Mistake | Result | |---|---| | Calling `complete` or `completeError` twice (for example, both an error and a timeout callback fire) | `StateError('Future already completed')` thrown from the second callback | | A code path that never completes (an early `return`, an ignored cancel callback) | The future never completes; every `await` on it hangs silently | | `completeError(e)` without the stack trace | The awaiting side gets a stack trace pointing at the completer, not at the real failure | | `complete()` with no value when `T` is non-nullable | Throws, because `null` is not a valid `T` | | `completeError` while nothing listens to the future | Reported as an **uncaught error** to the zone, just like any failed future without a listener | | Using `Completer.sync()` from arbitrary code | Listeners run inside `complete`, possibly before the code that registered them has finished | Two of these deserve explanation. - **Racing callbacks.** Many callback APIs can call both an error and a completion handler, or a timeout can race the result. Guard with `if (!completer.isCompleted) completer.complete(value);`. Note that `isCompleted` becoming `true` does not mean listeners have run yet: the default completer delivers in a later microtask. - **Never completing.** Unlike a crash, a hang produces no log line. Every branch of the adapter — success, failure, cancellation, dispose — must settle the completer, typically with `completeError` on cancellation. ## Completer versus async/await A completer is for the **boundary** with callback code. If the value can be produced by awaiting other futures, write an `async` function instead: its errors, stack traces and single completion are handled for you, and there is no way to forget a branch. Reaching for a completer inside otherwise future-based code is usually a sign the logic could be a plain `async` function. ## Completer.sync `Completer.sync()` completes and runs listeners **immediately** inside `complete`. The SDK allows it only when completion is the final result of another asynchronous event — for example, the last statement of a stream's `onDone` callback. Anywhere else it can break the guarantee that a callback registered on a future never runs before the registering code has finished. If in doubt, use the default constructor.

  • If isCompleted is true, have the future's listeners already run?
    Not necessarily. `isCompleted` flips as soon as `complete` or `completeError` is called. The default completer notifies listeners in a later microtask, and completing with another future waits for that future first, so listeners may still be pending.
  • What happens if complete is given a Future that later fails?
    The completer adopts that future's result. When it fails, `completer.future` completes with the same error, so awaiting code sees the failure just as if `completeError` had been called.

saying these in an interview costs you the question

  • Calling complete a second time is silently ignored.
  • A Completer that is never completed eventually times out on its own.
  • isCompleted true means all listeners have already been notified.
  • Completer.sync is a safe drop-in speed-up for the default Completer.
  • completeError without a listener is simply dropped.