skip to content

A Dart command-line tool intermittently crashes with LateInitializationError on a late service set during startup; how do you diagnose it and redesign so late stops hiding the dependency?

level: seniorimportance: should knowfreq 30%

answer

  1. the message names the variable
  2. which path read before the write
  3. unawaited or failed async setup
  4. each isolate has its own globals
  5. construct only once the dependency is ready

basics

~20 s

Some path read the late variable before startup assigned it: a command that skips init, an unawaited or failed async setup, or another isolate's own unassigned copy. Fix it by passing the ready service through constructors.

solid answer

~50 s

I start from the message, which names the variable and says `has not been initialized`, and the stack trace, which shows the reader. Then I ask which path reached that read without passing the assignment: a subcommand that skips the init step, an `async` init that was not awaited, an init that threw and was swallowed so the tool carried on, or work moved to another isolate — each isolate has its own copies of top-level variables, so a global assigned in `main` is unassigned there. The deeper fix is to stop using `late` for a dependency whose readiness depends on runtime events. I build the service with an async factory, `await` it in `main`, and pass the finished object into the commands that need it as a `required` constructor argument, so an unready service is unrepresentable rather than a runtime crash.

code

dart · 25 lines
dart
// Before: readiness is an assumption.
late final Database db;

Future<void> init() async {
  db = await Database.open('app.db');
}

// After: readiness is a type-level fact.
class ExportCommand {
  ExportCommand({required this.db});
  final Database db;

  Future<void> run() async => print(await db.count());
}

Future<void> main(List<String> args) async {
  final db = await Database.open('app.db');
  await ExportCommand(db: db).run();
}

class Database {
  Database._();
  static Future<Database> open(String path) async => Database._();
  Future<int> count() async => 0;
}

go deeper

for a junior

Recall that this error means a late variable was read before it was assigned, and that the message names the variable.

for a middle

Explain the common causes: a skipped init path, an unawaited async init, a swallowed failure, and per-isolate globals.

for a senior

Show the redesign: an awaited factory, services passed as required constructor arguments into plain final fields, and failing fast at startup.

for a principal

Set the team rule for when late is acceptable, and make dependency wiring explicit so ordering bugs become compile errors across the codebase.

## The scenario A command-line tool has a global or a field like `late final Database db;`. `main` parses arguments, calls `init()`, which opens the database and assigns `db`, and then dispatches to a subcommand. Most runs work. Some crash with `LateInitializationError: Field 'db' has not been initialized.` The code *looks* correct because every author assumed `init()` always runs first. ## Step 1: read what the error already tells you - The **message names the variable** and the kind of failure. `has not been initialized` means a read before any write; `has already been initialized` means a second write to a `late final`. - The **stack trace shows the reader**, not the missing writer. The question is always: *which path got here without passing the assignment?* - The thrown object is an `Error`. It reports a bug; catching it and continuing hides the real defect. ## Step 2: find the path that skipped the assignment The usual causes, roughly in order of how often they appear: 1. **A code path that skips init.** A `--help` or `version` branch, an early `return`, or a new subcommand registered before the init call. 2. **Unawaited async initialisation.** `init()` is `async`, `main` calls it without `await`, and the first command runs while the database is still opening. The `unawaited_futures` lint helps catch this. 3. **Initialisation that failed.** `init()` threw — a missing file, a refused connection — and a broad `try`/`catch` logged it and let execution continue with `db` unassigned. 4. **A different isolate.** Work moved into `Isolate.run` or a spawned isolate reads the global. Isolates do not share memory; each has its **own copies of top-level and static variables**, so the value assigned in the main isolate does not exist there. 5. **Test or tool entry points** that construct commands directly and never call the production init. ## Step 3: recognise `late` as the smell `late` is honest when the design itself guarantees the order — a value assigned in one place that always runs before any read. Here it is not: readiness depends on runtime events (I/O succeeding, an `await` happening, the right entry point being used). `late` converts a missing dependency from a **compile error into a runtime crash**, and there is no API to ask whether the variable is set. The fix is structural, not a guard. ## Step 4: redesign so the unready state cannot exist - **Async factory, then construct.** Replace `init()` plus a `late` field with a static `Future<Database> open(...)` that returns a ready object; `await` it in `main`. - **Pass dependencies in.** Commands take the service as a `required` constructor parameter, stored in a plain `final` field. A command without a database no longer compiles. - **Fail fast at startup.** If opening fails, exit with a clear message before dispatching any command, instead of swallowing the exception. - **Send data, not globals, across isolates.** Pass the configuration the worker needs as the message or the closure's captured value, and open resources inside that isolate. - **Keep `late` where ordering is structural** — a private `late final` field assigned once in a lifecycle hook the framework guarantees, or a lazy `late final` with an initializer. | Symptom | Likely cause | Structural fix | |---|---|---| | crash only for one subcommand | that path skips init | construct commands with the service injected | | crash on fast machines only | init not awaited | async factory awaited in `main` | | crash after a logged I/O error | failed init swallowed | exit on failure before dispatch | | crash only in background work | per-isolate globals | pass data into the isolate | ## What the interviewer wants to hear That you diagnose from the message and trace to the **missing writer**, list the realistic ways an ordering assumption breaks, and then remove the assumption instead of adding `try`/`catch` or a nullable field with `!` everywhere — which only renames the same crash.

  • In Dart, why does a top-level late variable assigned in main appear unassigned inside Isolate.run?
    Isolates share no memory. Each isolate gets its own set of top-level and static variables, starting from their declared state, so an assignment made in the main isolate is invisible to the new one. Reading the late variable there throws `has not been initialized`. Pass the needed data into the isolate instead of relying on globals.
  • In Dart, why is switching the late field to a nullable type with ! at each use not a real fix?
    It trades `LateInitializationError` for a `TypeError` from `!` at the same moment, and it weakens the type by suggesting `null` is a meaningful state. The ordering bug remains. The fix is to make the unready state impossible, by constructing consumers only after the dependency exists and passing it in as a required argument.

saying these in an interview costs you the question

  • Wrap the read in try/catch and retry until the service is ready
  • The stack trace points at the code that forgot to assign the variable
  • A global assigned in main is visible to every isolate
  • Making the field nullable and using ! fixes the crash
  • LateInitializationError is caught by an on Exception clause