The 60 Dart questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Dart is a client-optimized, statically typed programming language created by Google and first released in 2011. It compiles to native machine code for mobile and desktop (via AOT) and to JavaScript or WebAssembly for the web, which is how one Dart codebase ships across platforms. It's best known as the language behind Flutter, so most Dart interviews happen inside a Flutter hiring loop and test both the language itself and how you apply it in a real app. According to Google's official Dart documentation, everything you can place in a variable is an object, type annotations are optional because Dart infers types, and privacy is a naming convention (a leading underscore) rather than a keyword. In interviews, Dart questions probe sound null safety, asynchronous programming (Future, Stream, isolates), and the object model (mixins, named and factory constructors), not memorized trivia. This page collects the 60 questions that come up most, each with a direct answer and runnable code. Increasingly the first Dart round runs as a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Dart Programming Tutorial - Full Course
Video: Dart Programming Tutorial - Full Course (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Dart certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Dart is a client-optimized, statically typed programming language from Google, first released in 2011. It compiles to native machine code for mobile and desktop through ahead-of-time compilation, and to JavaScript or WebAssembly for the web, so one codebase runs across every platform.
Its popularity comes almost entirely from Flutter: teams write UI once and ship to iOS, Android, web, and desktop. Stateful hot reload during development and ahead-of-time compilation for release give a fast feedback loop while you build and quick startup for users.
Key point: A one-line definition plus the Flutter connection beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Dart in 100 Seconds (Fireship, YouTube)
var declares a variable whose type is inferred and whose value can change. final holds a value set exactly once, at runtime, and can't be reassigned. const is a compile-time constant: its value must be known when the code compiles, and it's deeply immutable.
The quick rule: use final for values fixed after one assignment, const for values baked in at compile time, and var only when you genuinely need to reassign.
var count = 3;
count = 4; // ok, var can change
final name = "Asha";
// name = "Ben"; // error: final is set once
const pi = 3.14159; // known at compile time
final now = DateTime.now(); // runtime value: final works, const would notKey point: The follow-up is 'why can't now be const?'. Because const needs a compile-time value and DateTime.now() runs at runtime.
Sound null safety means types are non-nullable by default: a String can't hold null, and to allow null you write String?. The compiler tracks nullability and rejects code that might dereference null, so a whole class of runtime null errors becomes a compile error instead.
The word sound matters: if the type says non-nullable, the runtime guarantees it, no escape hatches from mixed legacy code. This is the single most asked Dart topic in interviews.
String name = "Asha";
// name = null; // compile error, String is non-nullable
String? nickname; // allowed to be null
nickname = null; // fine
print(nickname?.length); // null-aware access, prints null safelyKey point: Say the word 'sound' and explain it. Candidates who only know ? without the guarantee sound shallower.
? in a type (String?) makes it nullable. ?? gives a default: a ?? b returns a if non-null, else b. ?. calls a member only if the target is non-null, otherwise the whole expression is null. ! asserts a nullable value is actually non-null and throws if it's wrong.
String? name;
print(name ?? "Guest"); // Guest
print(name?.length); // null (no crash)
name = "Asha";
print(name!.length); // 4, ! asserts non-nullKey point: Volunteering that ! throws is what separates understanding from pattern-matching syntax. Interviewers watch for casual ! overuse.
The everyday types are int and double (both num), String, bool, and the collections List, Set, and Map. There's also the special Object (base of everything except Null), dynamic (opts out of static checks), and Null.
One detail interviewers like: int and double are both subtypes of num, so you can type a variable as num when it might be either. And there's no separate char type; a single character is just a one-length String.
int age = 30;
double price = 9.99;
num either = 5; // holds int or double
String city = "Tokyo";
bool active = true;
List<int> scores = [91, 84];
Map<String, int> ages = {"Asha": 31};A List is an ordered collection that allows duplicates and indexes by position. A Set is an unordered collection of unique values with fast membership tests. A Map stores key-value pairs with unique keys and fast lookup by key.
Pick by need: order and duplicates mean List, uniqueness means Set, lookup by a key means Map.
List<int> nums = [1, 2, 2, 3];
Set<int> unique = {1, 2, 3};
Map<String, int> ages = {"Asha": 31, "Ben": 26};
print(unique.contains(2)); // true
print(ages["Asha"]); // 31| List | Set | Map | |
|---|---|---|---|
| Ordered | Yes | No (by default) | Insertion order |
| Duplicates | Allowed | Not allowed | Unique keys |
| Access by | Index | Membership test | Key |
| Literal | [1, 2, 3] | {1, 2, 3} | {'a': 1} |
Key point: The empty-literal trap: {} is an empty Map, not a Set. To make an empty Set you write <int>{}.
A Dart function declares a return type, a name, and parameters, then a body in braces. When the body is a single expression, arrow syntax (=> expr) replaces { return expr; } with a shorter form.
Functions are first-class in Dart: you can store them in variables, pass them as arguments, and return them, which is what makes callbacks and higher-order functions natural.
int square(int n) {
return n * n;
}
int cube(int n) => n * n * n; // arrow syntax
void apply(int x, int Function(int) fn) => print(fn(x));
apply(3, square); // 9Positional parameters come in order. Optional positional parameters go in square brackets and can be omitted. Named parameters go in curly braces and are passed by name, which reads clearly at the call site. A named parameter marked required must be supplied.
Named parameters are everywhere in Flutter widget constructors, so the question expects fluency here.
String greet(String name, {String greeting = "Hi", required int age}) {
return "$greeting $name, age $age";
}
greet("Asha", age: 31); // "Hi Asha, age 31"
greet("Ben", greeting: "Hello", age: 26);Key point: Knowing that required is a keyword (not the old @required annotation) signals you're on Dart 2.12 or newer.
You embed an expression in a string with $name for a simple variable, or ${expression} for anything more complex like a method call or arithmetic. It's the readable way to build strings in Dart, and it beats gluing pieces together with the + operator, which reads worse and is easy to get wrong.
Dart also has adjacent string literals (two quoted strings next to each other join automatically) and triple-quoted strings for multi-line text.
var name = "Asha";
var items = [1, 2, 3];
print("Hi $name"); // Hi Asha
print("Count: ${items.length}"); // Count: 3
print("Upper: ${name.toUpperCase()}");A class groups fields and methods. The constructor initializes fields, and Dart's shorthand this.field syntax assigns a parameter straight to a field without a body. Every class has a default no-argument constructor unless you define your own.
class Point {
final int x;
final int y;
Point(this.x, this.y); // shorthand assigns fields
int get sum => x + y;
}
var p = Point(3, 4);
print(p.sum); // 7A named constructor gives a class more than one way to build an instance, using ClassName.name syntax. It's how Dart works around the lack of constructor overloading, and it documents intent at the call site.
You'll see this constantly in Flutter and core libraries, like Point.origin() or DateTime.utc(...).
class Point {
final int x, y;
Point(this.x, this.y);
Point.origin() // named constructor
: x = 0, y = 0;
}
var o = Point.origin(); // (0, 0)A class extends exactly one superclass, inheriting its fields and methods. You call the parent's constructor with super, and override a method by writing it again with the @override annotation. Dart has single inheritance, and mixins fill the gap when you need to share code across unrelated classes.
class Animal {
void speak() => print("...");
}
class Dog extends Animal {
@override
void speak() => print("Woof");
}
Dog().speak(); // WoofA getter computes a value that callers read like a field (obj.area), and a setter intercepts assignment (obj.width = 5). You define them with get and set, so a class can expose a clean property interface while hiding logic or validation behind it.
The point is evolution: you can a plain field and later swap in a getter or setter without changing a single caller comes first.
class Rect {
double width, height;
Rect(this.width, this.height);
double get area => width * height;
set side(double s) {
width = s;
height = s;
}
}
var r = Rect(2, 3);
print(r.area); // 6
r.side = 4; // now a squareThe cascade lets you run several operations on the same object without repeating the variable. obj..a()..b()..c = 1 calls a and b and sets c, then returns the object itself, which is handy when you're configuring one thing.
The catch worth mentioning: a cascade returns the original object, not the result of the last call, so it's for side effects, not for chaining transformations.
var buffer = StringBuffer()
..write("Hello")
..write(", ")
..write("world");
print(buffer.toString()); // Hello, worldKey point: Being able to The a cascade returns (the object, not the last call) is the common follow-up.
Inside a list, set, or map literal, Dart lets you use if and for directly to build the collection conditionally or from another iterable. It replaces awkward temporary lists and reads cleanly, especially in Flutter widget trees.
bool loggedIn = true;
var tabs = [
"Home",
"Search",
if (loggedIn) "Profile", // conditional element
for (var i in [1, 2, 3]) "Item $i",
];
// [Home, Search, Profile, Item 1, Item 2, Item 3]The spread operator inserts all the elements of one collection directly into another literal, so you build a combined list, set, or map in one expression. Writing ...list unpacks a list inline, and the null-aware form ...?list does the same but safely skips the whole thing when the source is null.
var base = [1, 2, 3];
List<int>? extra;
var all = [0, ...base, ...?extra, 4];
print(all); // [0, 1, 2, 3, 4]A Future represents a value that isn't ready yet, like the result of a network call. An async function returns a Future, and await pauses that function until the Future completes, then hands back the value. The event loop keeps running other work during the wait, so nothing blocks.
The mental model: async/await is sequential-looking code that's actually non-blocking underneath.
Future<String> fetchName() async {
await Future.delayed(Duration(seconds: 1));
return "Asha";
}
void main() async {
print("start");
var name = await fetchName();
print("got $name"); // runs after 1s, without blocking
}Key point: The trap is calling await 'blocking'. It suspends the function while freeing the event loop, which is the opposite of blocking a thread.
A typedef gives a name to a type, most usefully a function type. Instead of writing bool Function(int) everywhere, you name it and reuse the name, which makes signatures readable. In modern Dart it also aliases any type, not just functions.
typedef IntPredicate = bool Function(int);
List<int> where(List<int> items, IntPredicate test) =>
items.where(test).toList();
where([1, 2, 3, 4], (n) => n.isEven); // [2, 4]main is the entry point every Dart program runs first. It returns void (or Future<void> if it awaits) and can take an optional List<String> for command-line arguments. Execution starts here, and the app ends when main and any pending async work finish.
In a Flutter app, main is where you call runApp to boot the widget tree, so it's the one function you're guaranteed to write.
void main(List<String> args) {
print("Program starts here");
if (args.isNotEmpty) print("First arg: ${args.first}");
}??= assigns a value to a variable only if that variable is currently null. a ??= b leaves a alone when it already has a value, and sets it to b otherwise. It's the one-line way to lazily set a default without an if check.
It pairs with nullable fields and caches: assign the computed value the first time, then reuse it.
String? cached;
cached ??= computeName(); // sets it the first time
cached ??= computeName(); // skipped, already set
print(cached);An enum defines a fixed set of named values, like Status.active. Dart's enhanced enums (from Dart 2.17) go further: an enum can have fields, a constructor, and methods, so each value carries data and behavior instead of just a name.
enum Plan {
lite(99),
growth(499),
pro(999);
final int price;
const Plan(this.price);
bool get isPaid => price > 0;
}
print(Plan.growth.price); // 499
print(Plan.pro.isPaid); // trueFor candidates with working experience: language mechanics, async depth, and the questions that separate users from understanders.
A Future delivers a single asynchronous value once, then it's done. A Stream delivers a sequence of asynchronous events over time, and you listen to it, reacting to each one. Think Future for a one-off request and Stream for an ongoing feed.
You consume a Future with await; you consume a Stream with await for in an async function, or by calling .listen with a callback.
Stream<int> countTo(int n) async* {
for (var i = 1; i <= n; i++) {
yield i; // emits each value
}
}
void main() async {
await for (var value in countTo(3)) {
print(value); // 1, 2, 3
}
}| Future | Stream | |
|---|---|---|
| Values delivered | One | Many over time |
| Consumed with | await | await for or .listen |
| Good for | A single request | Events, sockets, user input |
| Async producer | async / return | async* / yield |
Key point: Naming async* and yield for producing a Stream, alongside async and await for a Future, shows you know both directions.
Watch a deeper explanation
Video: Dart Futures - Flutter in Focus (Flutter, YouTube)
A mixin is a bundle of methods and fields you add to a class with the with keyword, sharing behavior across classes that don't share a superclass. Because Dart allows only single inheritance, mixins are how you compose reusable behavior without deep class trees.
The difference: extends is an is-a relationship with one parent, while with layers in capabilities from several mixins. A class can mix in many, and their order matters because later mixins can override earlier ones.
mixin Swimmer {
void swim() => print("swimming");
}
mixin Walker {
void walk() => print("walking");
}
class Duck with Swimmer, Walker {}
var d = Duck();
d.swim();
d.walk();Key point: The follow-up is often 'mixin vs interface vs extends'. Have the one-line distinction ready for each.
An abstract class can't be instantiated and can hold both concrete and abstract methods, so it's for sharing partial implementation. Dart has no separate interface keyword: every class defines an implicit interface, and any class can implement another with the implements keyword, which forces it to provide every member.
So the choice is extends (inherit implementation) versus implements (adopt the contract only, write everything yourself).
abstract class Shape {
double area(); // no body, subclasses must define
void describe() => print("Area is ${area()}");
}
class Circle extends Shape {
final double r;
Circle(this.r);
@override
double area() => 3.14159 * r * r;
}A factory constructor is declared with the factory keyword and, unlike a normal constructor, doesn't have to create a fresh instance. It can return a cached object, an instance of a subtype, or an object built from complex logic. That flexibility is what enables caching, singletons, and parsing from JSON.
class Logger {
static final Map<String, Logger> _cache = {};
final String name;
Logger._internal(this.name);
factory Logger(String name) {
return _cache.putIfAbsent(name, () => Logger._internal(name));
}
}
identical(Logger("app"), Logger("app")); // true, same cached instanceKey point: The classic use the question needs is fromJson and caching. Mention that a factory can return null-free cached instances.
A const constructor lets you create compile-time constant instances of a class, as long as all fields are final. Two const objects built with the same arguments are the identical object in memory, not just equal, which saves allocations.
In Flutter this matters for performance: a const widget is created once and skipped during rebuilds, so const constructors are a real optimization, not just style.
class Point {
final int x, y;
const Point(this.x, this.y); // const constructor
}
const a = Point(1, 2);
const b = Point(1, 2);
identical(a, b); // true, canonicalized to one instanceDart handles failures with try, catch, and finally. The catch clause can take one argument (the error) or two (the error and its stack trace), and an on clause narrows to a specific exception type so you handle each kind differently. You raise a failure with throw, and any finally block always runs for cleanup, whether or not something threw.
Dart splits failures into Error (bugs you shouldn't catch, like a failed assertion) and Exception (recoverable conditions). Catch Exceptions; let Errors surface so you fix the bug.
try {
var value = int.parse("abc");
} on FormatException catch (e) {
print("bad number: ${e.message}");
} catch (e, stack) {
print("other error: $e");
} finally {
print("always runs");
}Key point: Knowing the Error vs Exception distinction, and that you shouldn't catch Errors, indicates production experience.
Generics let a class or function work over a type parameter, so List<int> and List<String> reuse one implementation with type safety. You declare a placeholder like <T> and the compiler checks usage, which catches type errors at compile time instead of runtime.
You can bound a type parameter with extends to require a capability, like <T extends num> so the code can do arithmetic on T.
T firstOr<T>(List<T> items, T fallback) =>
items.isEmpty ? fallback : items.first;
firstOr<int>([], -1); // -1
firstOr(<String>["a"], "?"); // a
num total<T extends num>(List<T> xs) => xs.fold(0, (a, b) => a + b);With async/await you wrap the await in a normal try/catch, which reads cleanly. Without await, you attach .catchError to the Future, or pass onError to .then. A Future that fails and has no handler surfaces as an unhandled async error.
The practical guidance: prefer try/catch around await for readable flow, and always handle the failure path so errors don't vanish silently.
Future<int> risky() async => throw StateError("boom");
void main() async {
try {
await risky();
} catch (e) {
print("caught: $e");
}
// callback style
risky().catchError((e) => print("also caught: $e") as int);
}map transforms each element, where filters, reduce and fold combine to a single value, any and every test conditions, and expand flattens. These are lazy on an Iterable, so nothing runs until you materialize with toList or iterate.
Reaching for these instead of manual loops is a strong fluency signal, and knowing they're lazy explains why you sometimes need a trailing .toList().
var nums = [1, 2, 3, 4, 5];
var result = nums
.where((n) => n.isOdd)
.map((n) => n * n)
.toList();
print(result); // [1, 9, 25]
var total = nums.fold(0, (sum, n) => sum + n); // 15By default == compares identity: two objects are equal only if they're the same instance. To get value equality you override == and hashCode together, comparing the fields that define the object and hashing the same fields.
The rule interviewers check: if you override ==, you must override hashCode consistently, or the object misbehaves in Sets and Map keys. Many teams generate both with a package like equatable to avoid mistakes.
class Money {
final int amount;
final String currency;
const Money(this.amount, this.currency);
@override
bool operator ==(Object other) =>
other is Money &&
other.amount == amount &&
other.currency == currency;
@override
int get hashCode => Object.hash(amount, currency);
}Key point: Object.hash for combining fields is the modern idiom. Mentioning it beats hand-rolling amount.hashCode ^ currency.hashCode.
A record is a lightweight, immutable bundle of values, with positional and named fields, written in parentheses like (1, name: 'Asha'). It's the clean way to return multiple values from a function without defining a class or abusing a List.
Records have value equality built in, so two records with the same fields are equal, and you can destructure them with patterns.
(int, int) minMax(List<int> xs) {
xs.sort();
return (xs.first, xs.last);
}
var (lo, hi) = minMax([3, 1, 2]);
print("$lo..$hi"); // 1..3
({String name, int age}) person = (name: "Asha", age: 31);Patterns let you destructure and test values in one step, in switch cases, if-case statements, and variable declarations. A switch can match on shape, extract fields, and add guards with when, replacing chains of type checks and casts.
Combined with sealed classes, switch becomes exhaustive: the compiler errors if you forget a case, which is the safety win teams care about.
sealed class Shape {}
class Circle extends Shape { final double r; Circle(this.r); }
class Square extends Shape { final double s; Square(this.s); }
double area(Shape shape) => switch (shape) {
Circle(:var r) => 3.14159 * r * r,
Square(:var s) => s * s,
};A sealed class can be extended only within its own file, so the compiler knows the complete set of subtypes. That completeness makes switch statements exhaustive: cover every subtype or get a compile error, which catches the bug where you add a new case and forget to handle it.
Sealed classes pair naturally with pattern matching to model a closed set of states, like the results of an operation.
sealed class Result {}
class Ok extends Result { final String data; Ok(this.data); }
class Err extends Result { final String msg; Err(this.msg); }
String show(Result r) => switch (r) {
Ok(:var data) => "ok: $data",
Err(:var msg) => "error: $msg",
}; // exhaustive: no default neededAn extension adds methods or getters to an existing type you don't own, like String or int, without subclassing or wrapping it. The compiler resolves the extension statically, so it feels like the type gained a new method.
Use them to add small helpers to library types cleanly. The limitation to name: extensions are resolved by static type, so they don't participate in dynamic dispatch.
extension StringExtras on String {
String get reversed => split('').reversed.join();
bool get isBlank => trim().isEmpty;
}
print("dart".reversed); // trad
print(" ".isBlank); // trueA final list can't be reassigned, but its contents can still change: you can add to it. A const list is deeply immutable: the reference is fixed and any attempt to mutate it throws at runtime. const also canonicalizes, so two identical const lists are the same object.
So final protects the variable; const protects the whole structure.
final a = [1, 2, 3];
a.add(4); // ok, final only fixes the binding
const b = [1, 2, 3];
// b.add(4); // runtime error: Unsupported operationKey point: Candidates often think final means immutable. Correcting that (final fixes the binding, not the contents) is a quick depth signal.
is tests whether an object has a type and returns a bool; is! is its negation. as casts an object to a type and throws if the object isn't that type. There's no as? operator; for a safe cast you combine is with a check or use pattern matching.
A useful detail: after a successful is check, Dart promotes the variable to that type inside the block, so you don't need a separate cast.
Object value = "hello";
if (value is String) {
print(value.length); // promoted to String, no cast needed
}
var text = value as String; // throws if not a Stringlate declares a non-nullable variable that you promise to initialize before use, deferring the assignment. It's for values you can't set at declaration but that are never legitimately null, like a field set in a Flutter initState. Accessing a late variable before it's assigned throws.
late also enables lazy initialization: late final x = expensive() runs expensive() only on first access.
class Service {
late final String token; // set later, still non-nullable
void init() => token = fetchToken();
}
late final expensive = compute(); // runs only when first readKey point: The risk to acknowledge: late trades a compile-time null check for a runtime throw, so it's a promise you can break. Interviewers like hearing that trade-off.
Dart 3 added modifiers that constrain how a class can be used. final blocks both extending and implementing it outside its library. base forces subtypes to extend (not implement) so they inherit the implementation. interface allows implementing but not extending. They let a library author control its contract.
final class Config { // cannot be extended or implemented elsewhere
final String env;
Config(this.env);
}
base class Repository {} // subtypes must extend, keeping the implementationThe dart:convert library gives jsonEncode to turn a Map or List into a JSON string and jsonDecode to parse a string back into Dart objects. jsonDecode returns dynamic (usually Map<String, dynamic>), so you map it into a typed model, commonly with a fromJson factory and a toJson method.
For larger apps, code generation (json_serializable) writes those fromJson and toJson methods for you, which avoids hand-written mapping bugs.
import 'dart:convert';
class User {
final String name;
final int age;
User(this.name, this.age);
factory User.fromJson(Map<String, dynamic> j) =>
User(j['name'] as String, j['age'] as int);
}
var user = User.fromJson(jsonDecode('{"name":"Asha","age":31}'));advanced rounds probe internals, concurrency, and design judgment. Expect every answer here to draw a follow-up.
An isolate is Dart's unit of concurrency: its own memory heap and its own event loop, running independently. Unlike threads, isolates share no memory, so there are no locks and no data races. They communicate only by passing messages over ports, and the message is copied.
This model is why Dart avoids a whole category of concurrency bugs, at the cost of not sharing objects directly. For heavy CPU work you spawn an isolate; for a one-off computation, Isolate.run is the short path.
import 'dart:isolate';
int heavySum(int n) {
var total = 0;
for (var i = 0; i < n; i++) total += i;
return total;
}
void main() async {
var result = await Isolate.run(() => heavySum(100000000));
print(result); // runs off the main isolate, UI stays responsive
}Key point: Say the phrase 'share nothing, message passing'. It's the one-line summary that signals you actually understand the model.
Watch a deeper explanation
Video: Isolates and Event Loops - Flutter in Focus (Flutter, YouTube)
Each isolate runs one thread with an event loop and two queues: a microtask queue and an event queue. The loop drains all microtasks first, then handles one event (like a completed I/O or a timer), then drains microtasks again. Because it's single-threaded, only one piece of Dart code runs at a time.
This is why a long synchronous computation freezes the UI: it hogs the loop. The fix is to break work up, await I/O, or move CPU work to another isolate.
Key point: Explaining microtask-before-event ordering is a senior-level distinction most candidates miss.
Use async/await for I/O-bound waiting: network calls, file reads, timers. The work isn't running on the CPU, it's waiting, and the event loop stays free during the await, so one isolate handles thousands of concurrent I/O operations fine.
Reach for an isolate when the work is CPU-bound: parsing a huge JSON payload, image processing, heavy computation. That work would block the single event loop and drop frames, so you move it to a separate isolate that runs in parallel.
| Work type | Right tool | Why |
|---|---|---|
| I/O-bound (network, disk) | async / await | Event loop is free during the wait |
| CPU-bound (parsing, math) | isolate | Runs in parallel, keeps the loop responsive |
| Many concurrent waits | async / await | One isolate handles thousands of awaits |
Key point: The trap is reaching for isolates on I/O. Making clear that async already handles waiting is exactly what this question screens.
Watch a deeper explanation
Video: Async vs Isolates | Decoding Flutter (Flutter, YouTube)
Dart runs two ways. During development it uses a just-in-time compiler, which enables stateful hot reload: you change code and see it live without losing app state. For release it uses ahead-of-time compilation to native machine code, giving fast startup and predictable performance with no runtime compilation.
So you get the best of both: a fast edit loop while building, and a lean native binary when you ship. On the web, Dart instead compiles to JavaScript or WebAssembly.
Key point: Connecting JIT to hot reload and AOT to release performance is the answer interviewers hope to hear, not just the acronyms.
Dart uses a generational garbage collector. Most objects die young, so a fast young-space collector (a copying scavenger) reclaims short-lived objects cheaply, and survivors get promoted to old space, which a slower mark-sweep collector handles less often. The collector runs per isolate, and because isolates share no memory, collection never needs to stop other isolates.
The practical takeaway for UI work: Dart's GC is tuned to run in small steps that fit between frames, which is why Flutter apps stay smooth despite automatic memory management.
A StreamController is the object you use to create a Stream and push events into it manually with add. A single-subscription stream allows exactly one listener and is the default, right for a sequence consumed once, like reading a file. A broadcast stream allows many simultaneous listeners, right for events like user actions that several parts of the app react to.
The gotcha to name: listening to a single-subscription stream twice throws, and a broadcast stream doesn't buffer events for late listeners, so they miss anything emitted before they subscribed.
final controller = StreamController<int>.broadcast();
controller.stream.listen((v) => print("A: $v"));
controller.stream.listen((v) => print("B: $v"));
controller.add(1); // both A and B receive 1
controller.close();Dart generics are covariant by default: List<int> is treated as a subtype of List<num> because int is a subtype of num. That's convenient but not fully sound for writes, so Dart inserts a runtime check on assignment into a covariantly-typed collection, which can throw if you store the wrong type.
The senior point: covariance is a deliberate ergonomics-versus-soundness trade-off, and the runtime check is how Dart keeps it safe. You can also mark a parameter covariant explicitly to override a method with a narrower type.
List<num> nums = <int>[1, 2, 3]; // covariant: allowed
// nums.add(1.5); // runtime error: not an int
print(nums.first); // reading is always safedart:ffi (foreign function interface) lets Dart call directly into native C libraries: you load a shared library, describe the function signatures with typed bindings, and invoke them with low overhead. It's how you reuse an existing C or C++ library, or call platform APIs, without a plugin channel.
Reach for it when you need a mature native library or maximum performance for a specific routine. The cost is manual memory management across the boundary and platform-specific builds, so it's a targeted tool, not a default.
A zone is an execution context that can intercept and control asynchronous callbacks, uncaught errors, and even print. runZonedGuarded wraps code so any uncaught async error inside it lands in one handler, which is how apps install a top-level error reporter that catches failures from timers and futures that a plain try/catch would miss.
You rarely create zones by hand, but understanding them explains how error-tracking and testing frameworks capture async errors globally.
import 'dart:async';
void main() {
runZonedGuarded(() {
Future.delayed(Duration.zero, () => throw StateError("async boom"));
}, (error, stack) {
print("reported: $error"); // catches the async error
});
}When you build two const values with the same type and arguments, Dart creates only one object and hands out the same instance everywhere, which is canonicalization. That saves allocations and makes identity comparisons cheap.
In Flutter this is a real optimization: a const widget is canonicalized and its element can be reused across rebuilds instead of reconstructed, so marking widgets const where possible cuts work in the build phase.
Key point: Tying canonicalization to Flutter's const widget skip-rebuild behavior shows you language internals connects to real app performance.
Even with garbage collection, memory grows when something keeps references alive: a Stream subscription that's never cancelled, a listener or timer that outlives its owner, a global cache that only grows, or a closure capturing a large object. In Flutter the classic case is not cancelling subscriptions and controllers in dispose.
Diagnosis: watch memory in DevTools, take heap snapshots and diff them over time to find the growing allocation, and trace retaining paths to see what holds the reference. The fix is almost always cancelling subscriptions, disposing controllers, and bounding caches.
Incrementally, from the bottom up. Dart supported a mixed mode where null-safe and legacy libraries coexist, so you migrate leaf packages first, then packages that depend on them, keeping the app runnable throughout. The dart migrate tool proposes nullability annotations you review rather than accept blindly.
The judgment interviewers probe: resist sprinkling ! to silence the analyzer, because that reintroduces exactly the runtime crashes null safety prevents. Prefer real nullable types, ?? defaults, and required parameters.
Bottom-up null safety migration
Mixed mode lets null-safe and legacy libraries coexist, so the app stays runnable throughout.
Key point: Naming the anti-pattern (spamming ! to make errors go away) is what separates a migration you've done from one you've read about.
on restricts which classes can use a mixin: mixin M on Base means only classes that extend Base can mix in M. In return, the mixin gets to call members of Base and use super to reach the superclass's implementation. It's how a mixin declares a dependency on the behavior it augments.
class Widget {
void render() => print("base render");
}
mixin Logging on Widget {
@override
void render() {
print("before");
super.render(); // allowed because of 'on Widget'
}
}
class Button extends Widget with Logging {}A Completer gives you a Future whose completion you control by hand: you hold the Completer, hand out its .future, and later call complete(value) or completeError(error). It bridges callback-based or event-based APIs that don't already return Futures into the async/await world.
The rule to state: complete a Completer exactly once, and guard against completing an already-completed one. When an API already returns a Future, you don't need a Completer, so reaching for one on plain async code is a smell.
import 'dart:async';
Future<String> waitForCallback() {
final completer = Completer<String>();
legacyApi(onDone: (result) => completer.complete(result));
return completer.future;
}The test package is the standard: test files end in _test.dart, test() holds a single case with expect() assertions, and group() organizes related cases. setUp and tearDown handle per-test fixtures. You run everything with dart test, and mocktail or mockito fake out external boundaries.
A strong answer covers testing behavior over implementation, using async matchers like expectLater for streams and futures, and isolating I/O so tests stay fast and deterministic.
import 'package:test/test.dart';
int discount(int price, int pct) => price - (price * pct ~/ 100);
void main() {
group('discount', () {
test('applies percent', () => expect(discount(100, 10), 90));
test('zero percent', () => expect(discount(100, 0), 100));
});
}Dart lets a class define what operators like +, -, ==, [], and < mean for its instances by declaring them with the operator keyword. This is how value types read naturally: adding two Money objects or comparing two Vectors looks like arithmetic instead of method calls.
The discipline to mention: overload only when the operator's meaning is obvious, and keep == consistent with hashCode. A surprising + is worse than a plain method.
class Vector {
final double x, y;
const Vector(this.x, this.y);
Vector operator +(Vector o) => Vector(x + o.x, y + o.y);
Vector operator *(double s) => Vector(x * s, y * s);
}
var v = Vector(1, 2) + Vector(3, 4); // (4, 6)A generator function produces a sequence lazily with yield. sync* returns an Iterable and computes values on demand as you iterate. async* returns a Stream and can await between yields, so it emits values over time. yield* delegates to another generator, flattening it in.
Use sync* for lazy synchronous sequences and async* when producing a Stream from asynchronous steps, like paging through an API.
Iterable<int> naturals(int n) sync* {
for (var i = 1; i <= n; i++) yield i;
}
Stream<int> ticks(int n) async* {
for (var i = 1; i <= n; i++) {
await Future.delayed(Duration(milliseconds: 100));
yield i;
}
}Clarify first: bounded size, time-based expiry, or both? Then model an entry as a value plus an insertion timestamp, stored in a Map keyed by the request. On read, check whether the entry exists and is still within its TTL; if it's stale, evict and treat it as a miss. For size bounds, track access order and evict the least-recently-used entry when full.
The closing step is the operational edges: making it generic over key and value types, guarding concurrent access if it's touched from multiple async paths, and a Timer or lazy check to purge expired entries. Structuring the answer clarify, data model, read/write paths, eviction is what the question checks.
class TtlCache<K, V> {
final Duration ttl;
final _store = <K, (V, DateTime)>{};
TtlCache(this.ttl);
V? get(K key) {
final entry = _store[key];
if (entry == null) return null;
if (DateTime.now().difference(entry.$2) > ttl) {
_store.remove(key);
return null;
}
return entry.$1;
}
void set(K key, V value) => _store[key] = (value, DateTime.now());
}Structuring the cache design answer
the scoping and expiry logic more than the exact API you land on is the technical point.
Key point: how you scope requirements and handle expiry, not the exact API is the technical point.
Future.wait takes a list of Futures, starts them all, and returns a single Future that completes with the list of results once every one finishes. It's how you fan out independent I/O and wait for the whole batch instead of awaiting each in series.
The behavior to name: by default Future.wait fails fast, if any Future throws, the returned Future errors, though the others keep running. When you need every result including failures, use eagerError control or wrap each Future so one failure doesn't sink the batch.
Future<int> fetch(int id) async {
await Future.delayed(Duration(milliseconds: 100));
return id * 10;
}
void main() async {
var results = await Future.wait([fetch(1), fetch(2), fetch(3)]);
print(results); // [10, 20, 30], all ran concurrently
}Key point: Contrasting Future.wait (parallel) with a for loop of awaits (serial) is the distinction interviewers probe for latency understanding.
Annotations are metadata you attach with @, like @override, @deprecated, and @pragma. Some are advisory to the analyzer (@override flags a typo in a method name), and custom annotations become inputs to code generation, where a build step reads them and emits code.
That's the pattern behind json_serializable and similar tools: you annotate a class, and a generator produces the boilerplate. Annotations themselves do nothing at runtime unless a tool or reflection reads them.
class Base {
void update() {}
}
class Child extends Base {
@override
void update() {} // analyzer errors if update() doesn't exist in Base
@deprecated
void oldMethod() {}
}Dart wins when you want one codebase across mobile, web, and desktop with a fast, predictable UI, which is exactly the Flutter promise. It compiles ahead of time to native code for release builds and just in time during development, so you get both quick startup in production and stateful hot reload while you work. Where it competes is against the platform-native and cross-platform options a team already knows: Kotlin, Swift, and JavaScript. Knowing these trade-offs out loud is itself an interview signal, because it shows you pick tools on merits, not habit.
| Language | Typing | Best at | Watch out for |
|---|---|---|---|
| Dart | Static, sound null safety | Flutter cross-platform apps, one codebase | Smaller ecosystem outside Flutter |
| Kotlin | Static | Native Android, JVM backends | iOS story needs KMP, less mature UI sharing |
| JavaScript | Dynamic | Web and full-stack, huge ecosystem | Type quirks, no sound null safety |
| Swift | Static | Native iOS and Apple platforms | Not cross-platform outside Apple |
Prepare in layers, and practice out loud. Most Dart rounds move from concept questions to live coding to a Flutter or design discussion, so rehearse each stage rather than only reading answers.
The typical Dart interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Dart questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works