Dart's core types are int and double (both subtypes of num), String, bool, and the collections List, Set and Map. Every value is an object, even 3 and null, so numbers have methods like 3.isOdd.
**int / double**: whole and floating-point numbers; num accepts either. int is 64-bit on mobile and desktop, but exact only up to 2^53 on the web
**String**: immutable UTF-16 text; '$name' and '${a + b}' interpolate values
**bool**: only true and false; Dart has no truthy values, so if (list.length) does not compile
**List, Set, Map**: generic collections: List<int>, Map<String, User>
Special types: Object is the root of every non-null value, dynamic turns static checks off, void means "no useful value", Never means "never returns normally"
FlutterJunior
Dart Basics
Variables, types, functions, null safety, and core Dart syntax
Questions closed
0 / 22
- 0Review
- 5Asked often
Questions
var asks the compiler to infer a static type from the initializer; dynamic switches static type checking off; Object? is a real type that accepts any value but lets you call almost nothing until you check what it is.
**var**: var name = 'Alice' is a String forever; assigning 42 later is a compile error. final infers the type the same way
**dynamic**: every member call compiles, and a typo becomes a NoSuchMethodError at runtime. Its one routine use is the output of jsonDecode, and even there you cast each field on the way in
**Object?**: only toString(), hashCode, == and runtimeType are available until you narrow it with if (value is String); after that the String API opens up
Common mistake: var x; without an initializer is dynamic; give it a value or write the type
Both mean the variable is assigned once. final gets its value at runtime, when that line executes; const must be a compile-time constant, and the object it creates is deeply immutable and canonicalized: one shared instance.
**final**: any value, including DateTime.now() or a server response; the object itself can still change, so a final List accepts add
**const**: only literals, other constants and const constructors; const now = DateTime.now() does not compile
Canonicalization: two equal const expressions are the same object (identical returns true), so a repeated constant is stored once
Fields: an instance field can be final but not const; a class-level constant is static const
In Flutter: a const widget is the same instance on every build, so the framework skips rebuilding that subtree
A type without ? can never hold null, and the compiler enforces it; String? explicitly allows null. "Sound" means the guarantee also holds at runtime, so a non-nullable variable is never null and the compiler can rely on that.
Non-nullable by default: String name; must be assigned before use; String? email; starts as null
The compiler forces a check: you cannot call email.length until you handle null with an if, ?. or ??
Type promotion: after if (email != null) a local variable is treated as String. Public fields and getters do not promote, because their value could change between the check and the use; private final fields do since Dart 3.2
**late**: a non-nullable variable you promise to assign before the first read; reading it earlier throws LateInitializationError
Why it matters: null crashes move from production to the analyzer, and since Dart 3 every program runs with sound null safety; there is no unsound mode left
They handle null without writing if statements: ?. accesses a member only when the receiver is not null, ?? supplies a fallback, ??= assigns only when the variable is null, and ! asserts that a value is not null and throws if it is.
**a?.b**: evaluates to null when a is null, otherwise to a.b; the rest of the chain is skipped too
**a ?? b**: a if it is not null, otherwise b; b is not evaluated when a has a value
**a ??= b**: assigns b only when a is null; handy for a lazily created cache
**a?..b()**: null-aware cascade: the whole cascade is skipped when a is null
**[...?list]**: spreads a collection that may be null; adds nothing for null
**a!**: casts away nullability; if a is null you get a runtime TypeError: "Null check operator used on a null value"
Common mistake: using ! to silence the analyzer; prefer ?., ?? or an early return, and keep ! for values you have just proven non-null
is and is! test a value's runtime type and return a bool, and a successful check promotes the variable to that type. as is a cast: it tells the compiler to treat the value as that type and throws a TypeError at runtime if the guess is wrong.
**is**: if (shape is Circle) promotes shape to Circle inside the branch, so no cast is needed
**is!**: the negation: after if (value is! String) return; the variable is a String below
**as: gives you nothing to react to: a wrong guess crashes with "type 'int' is not a subtype of type 'double' in type cast"
No as?**: Dart has no safe cast; write value is String ? value : null
The JSON trap: on mobile and desktop, jsonDecode turns 10 into an int and 10.0 into a double, so json['price'] as double works for one product and crashes for another; write (json['price'] as num).toDouble()
Rule of thumb: prefer is with promotion; use as only where a wrong type really means a bug
By default a parameter is positional and required. Parameters in [ ] are optional positional; parameters in { } are named, optional unless marked required, and passed by name in any order.
Named: void greet({required String name, int age = 0}), called as greet(age: 5, name: 'Ann'); required makes the caller pass it
Optional positional: void log(String msg, [String level = 'INFO']), called as log('hi') or log('hi', 'ERROR'); they come last
Defaults: an optional parameter without a default must be nullable, because it may be omitted
Cannot mix: one function has either [ ] or { } optional parameters, not both
Why Flutter prefers named: a widget with ten arguments stays readable, and adding a new optional parameter does not break existing callers
In Dart a function is a value: you can store it in a variable, pass it to another function and return it. An arrow function is a short body syntax, an anonymous function has no name, and a closure is a function that keeps using variables from the scope it was created in.
Arrow function: int twice(int x) => x * 2;, where => returns the value of a single expression
Anonymous function: written where it is needed: list.map((x) => x * 2), onPressed: () { ... }
Closure: captures the surrounding variables themselves, not copies, and keeps them alive as long as the function lives, even after the outer function has returned
Why it matters in Flutter: every onPressed, builder and stream listener is a closure; a callback that captures a State keeps it in memory until the callback is released
Common mistake: thinking => means anonymous: a named function can use =>, and an anonymous one can have a { } body
A function type describes a signature: void Function(String) means "takes a String, returns nothing". A typedef gives any type a name, most often a callback signature, so you write it once instead of repeating it everywhere.
Function types: ReturnType Function(ArgTypes); use them for variables, parameters and return values
**typedef**: typedef OnSaved = void Function(String value);, then required OnSaved onSaved
Any type, not only functions: typedef Json = Map<String, dynamic>; shortens long generic types; it is only an alias, not a new type
Why bother: the contract is declared once; change it in one line and the compiler points at every caller that needs updating
In Flutter: VoidCallback and ValueChanged<T> are framework typedefs; use them instead of writing void Function() by hand
Since Dart 2.17 an enum is a class with a fixed set of constant instances: it can have final fields, a const constructor, methods and getters, and can implement interfaces. value.name gives the String, and Plan.values.byName(s) turns it back.
Built in: values lists the values in declaration order, index is the position, name the identifier; toString() gives Plan.pro, so never build keys from it
Unknown strings: byName throws ArgumentError on a name it does not know, such as a plan the server added after your release; for outside input use values.asNameMap()[raw] with a fallback
**Store name, not index: a value inserted in the middle shifts every index after it
Exhaustive switch**: without a _ arm a switch must name every value, so a new value makes the compiler list every place to update
Limits: fields are final, == and hashCode cannot be overridden, and nothing can extend an enum; when each case carries different data, use a sealed class
Choose by how you read the data: List keeps order and allows duplicates, Set holds unique values with a fast contains, Map looks values up by a unique key. All three are generic, and List and Set share the Iterable methods.
**List**: list[i], add, insert, removeAt, sort; contains scans every element, so it is O(n)
**Set**: add returns false for a duplicate, contains is O(1) on average; union, intersection, difference. An empty {} is a Map, so write <int>{} for an empty set
**Map**: map[key] returns null for a missing key; putIfAbsent, update, containsKey, entries. The default Map and Set keep insertion order
Iterable methods: map, where, firstWhere, any, every, fold, expand; map and where are lazy and return an Iterable, so call toList() when you need a list
Converting: list.toSet() removes duplicates; {for (final u in users) u.id: u} builds a map by id
Common mistake: calling list.contains inside a loop over another big list is O(n·m); turn the lookup side into a Set first
The cascade .. performs several operations on the same object and evaluates to that object. The spread ... inserts every element of one collection into a collection literal, and collection if / for add elements conditionally or in a loop right inside the literal.
Cascade: in Paint()..color = c..strokeWidth = 2, every .. targets the same Paint, and the expression returns it, not the last call's result
Spread: [...a, ...b], also in Set and Map literals; ...?maybe skips a null collection, and ?.. skips a cascade on a null receiver
**Collection if / for**: [header, if (isAdmin) adminTile, for (final i in items) Tile(i)]; this is how Flutter builds children without temporary lists
Common mistake: final l = [1].add(2); gives void, while final l = [1]..add(2); gives the list
It prints [1, 2, 3, 0, 4]. Variables hold references, and Dart passes a copy of the reference: b and both parameters point at the same list, so add changes it, while items = [99] only rebinds the local parameter.
**final b = a**: copies the reference, not the list; b.add(0) is visible through a
Reassigning a parameter: changes only the local name; the caller still holds the original list
Mutating through a parameter: add, remove, user.name = ... change the one shared object
Need a copy? Use List.of(a) or [...a]; the copy is shallow, so the elements themselves are still shared
Flutter trap: notifier.value.add(x) mutates the same list, so a ValueNotifier does not notify; assign a new list instead
Lines B, C and E throw an UnsupportedError; A and D work. final only freezes the variable, const and List.unmodifiable freeze the contents, and List.filled makes a fixed-length list whose elements can change but whose length cannot.
In the real program: execution stops at line B, so C and E never run; judge each line separately
**A: final list**: growable; you cannot reassign a, but you can change the list
**B: const list**: deeply immutable; add throws "Cannot add to an unmodifiable list"
**C: List.unmodifiable**: a read-only copy made at runtime; c[0] = 9 throws "Cannot modify an unmodifiable list"; useful for exposing internal state from a getter
**D and E: List.filled**: fixed length: d[0] = 9 is fine, add throws "Cannot add to a fixed-length list"; pass growable: true to allow growth
None of these is a compile error: the static type is List<int> in every case, so the analyzer cannot tell them apart
List.filled evaluates its fill value once, so every row is the same inner list object. Writing to cells[0][1] changes that one list, and the X shows up in every row.
Why: List.filled(n, value) stores the same reference in all n slots; that is harmless for immutable values such as 0, '.' or null, and wrong for anything mutable: a List, a Map or a model object
The fix: List.generate(size, (_) => List.filled(size, '.')) calls the generator once per index, so each row is a new list; a collection-for, [for (var i = 0; i < size; i++) List.filled(size, '.')], does the same
Same trap elsewhere: List.filled(3, <String>[]) for per-tab item lists, or one Map instance reused as the default value for many keys
Catching it: identical(cells[0], cells[1]) is true in the debugger; a unit test that places one mark and checks that the other rows are untouched catches it in one line
You wrap risky code in try, handle specific types with on Type, get the object and stack trace with catch (e, st), and clean up in finally. An Exception is an expected failure you should handle; an Error signals a bug you should fix, not catch.
**on vs catch**: on FormatException filters by type; catch (e, st) binds the object and the StackTrace; combine them as on FormatException catch (e)
Exceptions: FormatException, TimeoutException, SocketException: bad input or a failed request; show a message and carry on
Errors: ArgumentError, StateError, RangeError, TypeError: a bug; let it reach the crash reporter instead of swallowing it
**rethrow**: passes the same error up with its original stack trace; throw e starts a new trace at the current line
Your own failures: throw a class that implements Exception, not a String, so callers can catch it by type
Async: try / catch catches errors from awaited futures; an error from a future you did not await escapes it
assert(condition, message) checks your own assumptions during development. It runs only in debug builds and is stripped from profile and release, so a bad value that an assert catches on your machine passes silently in the store build.
Debug only: in release the expression is not even evaluated, so a side effect inside an assert disappears
Add a message: assert(percent <= 1, 'got $percent') shows the bad value in the error
Invariants, not input: data from users, JSON or a public API needs if (...) throw ArgumentError(...) or a FormatException, which also run in release
Debug-only code: use if (kDebugMode) { ... } from package:flutter/foundation.dart; it is a constant, so the compiler removes the branch from release builds
Rule of thumb: if a real user can trigger the bad value, it needs a throw, not an assert
It prints 0 1 2 3 3 3, one number per line. A for loop in Dart creates a fresh i for every iteration, so each closure keeps its own value; the while loop uses one j declared outside, so all three closures share it and see its final value, 3.
Closures capture variables, not values: the closure reads j when it runs, not when it is created
**for is special**: Dart gives each iteration its own copy of the loop variable, which is why the classic JavaScript var bug does not happen here
Anything declared outside the loop is shared: counters, while variables, fields
The fix: copy into a local inside the body: final current = j; callbacks.add(() => print(current));
Where it bites in Flutter: callbacks built in a while loop or from a shared index, where every onPressed ends up using the last value
user is a field, and the await lets other code run: if logout() sets it to null while the request is in flight, user! on the last line throws. The null check before the await proves nothing about the field after it.
Why the analyzer refused: a public, non-final field does not promote after user == null, precisely because its value can change between the check and the use; ! silenced that error instead of fixing the cause
**The await is the gap**: while fetchAvatarUrl runs, the event loop handles a tap on "Log out", a stream event or a timer, and any of them can reset the field
The fix: read the field once into a local, final current = user;: a local promotes to User, needs no ! and cannot change under you
Stale results: after the await, check user != current, so an avatar fetched for a logged-out or switched account is dropped instead of written
Catching it: treat every ! on a field as a review flag (DCM's avoid-non-null-assertion rule enforces this); a test that calls refreshAvatar() and then logout() without awaiting in between reproduces the crash
identical(a, b) checks whether two references point to the same object in memory; == checks equality as the class defines it. By default == is identity, until a class overrides it, as String, int and value classes do.
**Default ==**: inherited from Object and the same as identical; two User('Ann') objects are not equal unless User overrides == and hashCode
**Overridden ==**: 'ab' == 'a' + 'b' is true. Collections do not compare contents: [1] == [1] is false; use listEquals (from flutter/foundation.dart) or DeepCollectionEquality (from package:collection)
**const canonicalization**: equal const expressions produce one object, so identical(const Point(1, 2), const Point(1, 2)) is true without any == override
Flutter uses identity: when a widget instance is identical to the previous one, as a const widget is, the framework skips updating that subtree
Common mistake: overriding == without hashCode: Set and Map keys then treat equal objects as different
double is a 64-bit binary floating-point number, and values like 0.1 have no exact binary form, so small rounding errors appear and add up. Store money as an int count of the smallest unit (cents, tiyn) or use a decimal type, and format it only for display.
**Never compare doubles with ==**: compare with a tolerance: (a - b).abs() < 1e-9
**toStringAsFixed(2): hides the error in the UI but does not fix the stored value; the drift returns after more arithmetic
Decimal arithmetic**: the decimal package gives exact base-10 math for percentages and currency conversion
Web caveat: on Flutter web an int is exact only up to 2^53
Rule of thumb: double for measurements, int for counts and money in minor units, String for identifiers
A DateTime holds an instant (microseconds since 1970 UTC) plus an isUtc flag; it stores no time zone. Store and send UTC with an explicit Z, call toLocal() only for display, and compare instants with isAtSameMomentAs.
**DateTime.now(): local time; the device's offset is applied when you read fields or format it
DateTime.parse**: returns UTC when the string ends with Z or an offset, and local time when it does not, so '2026-01-01T10:00' means different instants in different countries
**== compares the flag too**: a UTC value and its local twin are not equal; use isAtSameMomentAs
**toIso8601String()**: writes Z only for UTC values, so a serialised local value loses its offset; send toUtc().toIso8601String()
DST trap: add(Duration(days: 1)) adds exactly 24 hours; for "same time tomorrow" use DateTime(y, m, d + 1, hour)
Another time zone: DateTime cannot represent "9:00 in Almaty" on a phone set to another zone; use the timezone package and TZDateTime