Every time I mentor someone new to Dart, we end up spending an entire session just on variables and data types — not because the syntax is hard, but because Dart quietly does more than most languages in this area. Between var, final, const, type inference, and sound null safety, there’s a surprising amount of depth hiding behind what looks like beginner material. I want to walk through all of it here: the basics you need on day one, and the internal reasoning that will make you a sharper Dart developer later.
What Is a Variable in Dart?
A variable is simply a named container that holds a reference to a value in memory. In Dart, every variable has a type, whether you write it explicitly or let the compiler infer it.
void main() {
String name = 'Ayesha';
int age = 24;
double height = 5.6;
bool isDeveloper = true;
print('$name is $age years old, $height ft tall, developer: $isDeveloper');
}
Output:
Ayesha is 24 years old, 5.6 ft tall, developer: true
This looks familiar if you’ve used Java, C#, or Kotlin. What makes Dart interesting is how flexible the declaration syntax is while still being fully statically typed underneath.
Declaring Variables: var, Explicit Types, final, and const
Using var
void main() {
var city = 'Lahore'; // inferred as String
city = 'Karachi'; // OK, still a String
// city = 5; // Error: A value of type 'int' can't be assigned to 'String'
print(city);
}
I want to be clear about something a lot of beginners misunderstand: var is not dynamic typing. Once Dart infers the type at the point of initialization, that variable is locked to that type for its entire lifetime. This is fundamentally different from JavaScript’s var, and confusing the two is one of the most common mistakes I see from developers coming from a JS background.
Explicit Typing
void main() {
String country = 'Pakistan';
int population = 240000000;
print('$country has a population of $population');
}
I explicitly type variables when the intent might not be obvious from the value alone, or when I’m writing public APIs/library code where clarity matters more than brevity.
final — Assign Once, Determined at Runtime
void main() {
final DateTime now = DateTime.now();
print(now);
// now = DateTime(2020); // Error: can't reassign a final variable
}
final variables can only be set once, but the value can be determined at runtime — like the current date and time above.
const — Compile-Time Constants
void main() {
const double pi = 3.14159;
const List<int> fixedNumbers = [1, 2, 3];
print(pi);
print(fixedNumbers);
}
const is stricter than final: the value must be known at compile time. This means you can’t do const now = DateTime.now(); because that value isn’t known until the program runs.
final vs const: A Practical Distinction
| Feature | final | const |
|---|---|---|
| Value determined | Runtime | Compile-time |
| Reassignable | No | No |
Can hold runtime values (API calls, DateTime.now(), user input) | Yes | No |
| Memory | Single instance in memory when initialized | Canonicalized — identical const values share the same memory instance |
That last row matters more than it sounds. Dart canonicalizes const values, meaning two const objects with the same value literally point to the same memory location.
void main() {
const a = [1, 2, 3];
const b = [1, 2, 3];
print(identical(a, b)); // true — same object in memory
final c = [1, 2, 3];
final d = [1, 2, 3];
print(identical(c, d)); // false — separate objects
}
In Flutter specifically, this is why you’ll see experienced developers write const Text('Hello') inside widget trees — it lets Flutter skip rebuilding that widget entirely during re-renders, since the framework knows the const instance can never change.
Dart’s Built-In Data Types
Dart’s core types fall into a few categories.
Numbers: int and double
void main() {
int wholeNumber = 42;
double decimalNumber = 3.14;
num flexibleNumber = 10; // can hold either int or double
flexibleNumber = 10.5; // valid, since num accepts both
print(wholeNumber.runtimeType); // int
print(decimalNumber.runtimeType); // double
print(flexibleNumber.runtimeType); // double
}
On native platforms, Dart’s int is a 64-bit signed integer. On the web (compiled to JavaScript), int is represented using JavaScript’s double-precision floating point numbers, meaning integers beyond 2^53 can lose precision. This is a real internal detail that has affected code I’ve shipped — if you’re doing large integer math (like handling 64-bit IDs from a backend), test on web specifically, since native platforms won’t reveal this issue.
Strings
void main() {
String greeting = 'Hello';
String name = "World";
String message = '$greeting, $name!'; // string interpolation
String multiline = '''
This is
a multiline string
''';
print(message);
print(multiline);
}
Dart strings are immutable sequences of UTF-16 code units. Every “modification” — like concatenation — actually creates a brand-new string in memory rather than mutating the original. This matters for performance in tight loops, which I cover further down.
Booleans
void main() {
bool isConnected = true;
bool hasError = false;
print(isConnected && !hasError);
}
Dart’s bool type is strict — unlike JavaScript, Dart never implicitly converts non-boolean values (like 0, "", or null) into true/false. An if statement in Dart requires an actual bool expression, which eliminates an entire class of “truthy/falsy” bugs.
Lists, Sets, and Maps
void main() {
List<String> fruits = ['apple', 'banana', 'mango'];
Set<int> uniqueIds = {1, 2, 3, 3}; // duplicates auto-removed
Map<String, int> ages = {'Ali': 25, 'Sara': 30};
print(fruits[0]); // apple
print(uniqueIds); // {1, 2, 3}
print(ages['Ali']); // 25
}
dynamic vs Object vs var
This trio confuses almost everyone at first, so let me lay it out clearly:
void main() {
dynamic dynamicVar = 'Hello';
dynamicVar = 42; // OK — dynamic bypasses compile-time type checking
Object objectVar = 'Hello';
// objectVar = 42; // OK to reassign to another Object subtype,
// but you can't call String-specific methods without a cast
var inferredVar = 'Hello';
// inferredVar = 42; // Error — locked to String after inference
}
var: type is inferred once, then fixed.Object(orObject?): the type is staticallyObject, so you can assign any value, but you must cast before calling type-specific methods.dynamic: disables static type checking entirely for that variable — the compiler trusts you completely, and errors only surface at runtime.
I use dynamic extremely sparingly — mostly when working with loosely typed JSON before I’ve modeled it properly. Outside of that, I always prefer var, explicit types, or generics, because dynamic throws away one of Dart’s biggest strengths: compile-time safety.
Type Inference: How Dart Figures Out Types Without Being Told
Dart’s static type inference happens at compile time using a type inference algorithm based on the value assigned during declaration, and — increasingly — the surrounding context (like function return types or generic type parameters).
void main() {
var numbers = [1, 2, 3]; // inferred: List<int>
var mixedList = [1, 'two', 3.0]; // inferred: List<Object>
var scores = <String, int>{}; // explicitly typed empty map
print(numbers.runtimeType); // List<int>
print(mixedList.runtimeType); // List<Object>
}
One place type inference gets genuinely clever is with generic functions:
T firstElement<T>(List<T> list) => list.first;
void main() {
var result = firstElement([10, 20, 30]); // T inferred as int
print(result.runtimeType); // int
}
Dart looks at the argument you passed (List<int>) and infers T as int without you writing a single type annotation. This is called contextual type inference, and it’s a major reason Dart code can be both concise and strongly typed at the same time.
Null Safety: Dart’s Biggest Type System Feature
Since Dart 2.12, sound null safety is the default, and it fundamentally changes how variables and types work. Every type is non-nullable by default, and you must explicitly opt in to nullability using ?.
void main() {
String name = 'Hamza'; // cannot be null, ever
String? nickname; // can be null, and is null by default
print(name);
print(nickname); // null
}
If I try to assign null to a non-nullable variable, Dart refuses to compile:
void main() {
String name = null; // Compile-time error:
// "A value of type 'Null' can't be assigned to a variable of type 'String'"
}
This is the difference between “sound” null safety and older, unsound approaches (like TypeScript’s optional null checking): Dart guarantees at compile time that a non-nullable variable can never hold null, and the Dart compiler and runtime enforce this guarantee end-to-end, including across libraries and packages.
Working with Nullable Types Safely
void main() {
String? city;
// Option 1: null-aware access
print(city?.toUpperCase()); // null, no crash
// Option 2: default value fallback
print(city ?? 'Unknown'); // Unknown
// Option 3: explicit null check (type promotion)
if (city != null) {
print(city.toUpperCase()); // Dart "promotes" city to String here
}
}
That third example demonstrates type promotion — one of the more elegant internal mechanics of Dart’s null safety. Once you check city != null inside an if block, the Dart analyzer treats city as a non-nullable String for the rest of that scope, so you don’t need the ! operator at all.
Late Variables
Sometimes you need to declare a non-nullable variable but initialize it after declaration — common in Flutter State classes.
class UserSession {
late String token;
void login(String receivedToken) {
token = receivedToken;
}
}
void main() {
var session = UserSession();
session.login('abc123');
print(session.token); // abc123
}
late tells Dart, “trust me, this will be initialized before it’s used.” If you access it before initialization, Dart throws a LateInitializationError at runtime — so it’s a deliberate trade-off between compile-time strictness and real-world initialization order (like widgets that need initState() to run first).
Internal Memory Behavior
A few internal details I think are genuinely useful to understand:
- Primitive-looking types are still objects. Even
int,double, andboolare objects in Dart (unlike Java’s primitiveint). The Dart VM optimizes small integers using techniques like tagged pointers so common operations remain fast without full heap allocation for every number. - Strings are immutable. Every string operation that “changes” a string (like
+=in a loop) actually allocates a new string object. For heavy string-building work, I useStringBufferinstead, which mutates an internal buffer and only creates the finalStringonce, at the end.
void main() {
final buffer = StringBuffer();
for (int i = 0; i < 5; i++) {
buffer.write('Item $i ');
}
print(buffer.toString());
// Output: Item 0 Item 1 Item 2 Item 3 Item 4
}
constcanonicalization reduces memory use. As shown earlier, identicalconstvalues are stored once and reused, which is especially valuable in Flutter, whereconstwidgets avoid both extra memory allocation and unnecessary rebuild cycles.- Garbage collection in Dart is generational — most objects (like local variables in a function) are short-lived and collected quickly via a fast “young generation” collector, while long-lived objects (like app-wide singletons) get promoted to an “old generation” that’s collected less frequently. This is why creating lots of small, short-lived objects in Dart (a very idiomatic style, especially in Flutter’s
build()methods) is generally cheap.
Real-World Flutter Use Cases
In real Flutter development, these concepts show up constantly:
class UserProfile {
final String name;
final int age;
final String? bio; // optional field, can be null
const UserProfile({
required this.name,
required this.age,
this.bio,
});
}
void main() {
const user = UserProfile(name: 'Zara', age: 28);
print(user.bio ?? 'No bio provided');
}
Notice how const constructors, final fields, and nullable types (String?) combine in a typical immutable data model — this pattern is everywhere in production Flutter apps, especially with state management libraries like Provider, Riverpod, or Bloc, where immutable state objects are the norm.
Best Practices I Follow
- Prefer
finalovervarfor any variable that won’t be reassigned — it communicates intent and prevents accidental mutation bugs. - Use
constwherever the value truly is a compile-time constant, especially for widgets in Flutter, since it improves rebuild performance. - Avoid
dynamicunless you’re dealing with genuinely dynamic data (like raw JSON before parsing into a model). - Model nullable fields deliberately — don’t make everything nullable “just in case.” Every
?you add is a decision that ripples through your codebase and forces null checks everywhere it’s used. - Use
latesparingly and only when you’re certain of initialization order — misusing it just trades a compile-time safety net for a runtime crash risk.
Common Mistakes
- Declaring everything as
dynamicto “make errors go away” — this defeats the entire purpose of Dart’s type system. - Assuming
varbehaves like JavaScript’s loosely-typedvar— it doesn’t; the type is locked after inference. - Overusing the null assertion operator (
!) instead of proper null checks or default values. - Forgetting that
constrequires compile-time constant values, then being confused by analyzer errors on things likeconst DateTime.now(). - Mixing up
finalandconstand assuming they’re interchangeable — they aren’t, especially for collections and objects with runtime-dependent construction.
Debugging and Troubleshooting Tips
- If you see
A value of type 'Null' can't be assigned to a variable of type 'X', you’re trying to assignnullto a non-nullable variable — either make the type nullable (X?) or provide a real default. - If you get a
LateInitializationError, it means alatevariable was accessed before it was assigned — trace the code path to confirm your initialization order assumption was wrong. - Use
object.runtimeTypeduring debugging to confirm what Dart actually inferred, especially with collections likeList<Object>vsList<int>. - Use Dart DevTools’ memory tab to inspect object allocation if you suspect excessive garbage collection from things like string concatenation in loops.
FAQs
Q: Is var the same as dynamic in Dart? No. var infers a specific static type at the point of declaration and locks to it. dynamic disables static type checking altogether, allowing any type at any point.
Q: Can a final variable be null? Yes, if its declared type is nullable (final String? name;). final only controls reassignment, not nullability.
Q: Why does Dart require null safety instead of leaving it optional? Since Dart 2.12, null safety is sound and required by default across the entire ecosystem, which lets the compiler eliminate an entire class of null-reference runtime errors and enables more aggressive compiler optimizations, since it can trust that non-nullable variables are genuinely never null.
Q: What’s the actual performance difference between const and final? const values are canonicalized and created once at compile time, so there’s no allocation cost at runtime, and in Flutter, const widgets can skip being rebuilt entirely. final values are computed once at runtime but still allocate memory normally like any other object.
Q: Should I always add ? to be “safe”? No — adding ? everywhere just moves the problem instead of solving it, forcing null checks throughout your codebase. Only make a variable nullable when null is a genuinely valid, meaningful state for that data.
Summary
Variables and data types are where Dart quietly reveals its design philosophy: safety without excessive verbosity. var and type inference keep code concise while remaining fully statically typed, final and const express different (and important) guarantees about immutability and memory, and sound null safety eliminates one of the most common sources of runtime crashes in software. Once these concepts click, a huge portion of “confusing” Dart and Flutter error messages start making a lot more sense.
References
- Official Dart Language Tour — Variables: https://dart.dev/language/variables
- Official Dart Language Tour — Built-in Types: https://dart.dev/language/built-in-types
- Dart Null Safety Documentation: https://dart.dev/null-safety
- Effective Dart: Usage Guidelines: https://dart.dev/effective-dart/usage
- Flutter Performance Best Practices: https://docs.flutter.dev/perf/best-practices