← Back to blog
Dart Fundamentals · Part 4 of 10
July 2, 202611 min read

Dart's Type System: Inference, Object, dynamic & Type Checks

DartFlutter

Dart's Type System

This is Part 4 of the Dart Fundamentals series. Back in Part 2 we met var vs dynamic and const vs final — the everyday keywords. This post goes one level deeper into the thing that quietly powers all of them: Dart's static type system.

We won't re-explain what var or const are (Part 2 has you covered). Instead we'll answer the questions that come next:

  • How does Dart figure out a type you never wrote down?
  • What's the real difference between Object, Object?, var, and dynamic?
  • How do you check a value's type at runtime — and cast it safely?

Let's go.


What "type inference" actually means

Dart is statically typed — every variable has a type that's fixed at compile time. But statically typed doesn't mean you have to type out every type. When you write:

var count = 10;

Dart looks at the value 10, sees it's an int, and permanently gives count the type int. You didn't write int, but the variable is exactly as strongly typed as if you had:

int count = 10; // identical to the line above

This is type inference: the compiler deduces the type from context so you don't have to spell it out. The key thing beginners miss — inference is not dynamic typing. The type is still locked in:

var count = 10;
count = 'hello'; // ❌ compile error — count is an int, forever

Inference works on more than literals

The compiler infers from whatever is on the right-hand side — a literal, a constructor, a function's return type, an expression:

var name = 'Vivek';            // String
var price = 9.99;              // double
var ids = <int>[1, 2, 3];      // List<int>
var now = DateTime.now();      // DateTime (from the return type)
var total = price * 2;         // double (from the expression)

Hover over any of these in your IDE and it'll show you the inferred type. That's a great habit when you're learning — let the editor confirm what Dart deduced.


When inference needs your help

Inference is great, but it can only work from information that's actually present. Two cases trip people up.

1. Declaring without initializing

If you split declaration and assignment, there's nothing for var to infer from:

var score;        // type is `dynamic` — usually NOT what you want
score = 10;
score = 'oops';   // ✅ allowed, because it became dynamic

When you don't give a value immediately, write the type yourself:

int score;        // ✅ explicitly typed, still null-safe
score = 10;

2. Empty collections

An empty literal gives the compiler nothing to infer the element type from:

var items = [];   // List<dynamic> — a trap
items.add(1);
items.add('two'); // allowed, but now your list is a mess

Annotate the element type so the list stays honest:

var items = <String>[]; // List<String>
// or
List<String> items = [];

Rule of thumb: let inference do its job when a value is right there on the line. The moment a value is missing or empty, write the type yourself.


Object, Object?, var, and dynamic — the four-way confusion

These look interchangeable. They are not. Here's the mental model that finally made it click for me.

var — "infer the type, then lock it"

var is not a type. It's an instruction to the compiler: "figure out the type from the value and fix it." After that line, the variable has a concrete type (int, String, whatever) — var itself disappears.

var x = 42;   // x is an int from here on

Object — "the type that everything is"

In Dart, every non-null value is an Object (remember from Part 2: there are no primitives — even 10 is an object). So a variable typed Object can hold anything non-null, but Dart only lets you use the handful of members that every object has (toString(), hashCode, ==):

Object thing = 42;
thing = 'now a string'; // ✅ both are Objects
print(thing.toString()); // ✅ every Object has toString()
print(thing.length);     // ❌ compile error — Object has no `length`

Object keeps you type-safe: the compiler still checks every call. You just have to prove the real type before you can use type-specific members (more on that below).

Object? — "anything, including null"

Object excludes null. Add ? and now it includes null too — Object? is the true "top type," the one thing every value in Dart fits into:

Object? maybe = null; // ✅
Object  no    = null; // ❌ Object can't be null

(We'll dig into what that ? really means in Part 5 on null safety.)

dynamic — "turn off the type checker"

dynamic looks like Object but does the opposite. It tells Dart: "trust me, don't check anything." Every member access compiles — and may blow up at runtime:

dynamic thing = 42;
print(thing.length); // ✅ compiles... 💥 crashes at runtime (int has no length)

That's the danger. With Object the mistake is caught while you type. With dynamic it's caught by your users.

Side-by-side

| Keyword | A real type? | Holds null? | Type-checked? | Use when… | | --------- | ------------ | ----------- | ------------------------ | ------------------------------------------ | | var | No (infers) | Depends | Yes (after inference) | The value is right there — your default. | | Object | Yes | No | Yes | You truly accept any non-null value. | | Object? | Yes | Yes | Yes | You accept anything, null included. | | dynamic | Yes (top) | Yes | No | Interop with untyped data (JSON) — rarely. |

The takeaway: reach for var by default, Object? when you genuinely need "anything," and dynamic almost never.


Checking types at runtime: is and is!

Sooner or later you'll hold an Object (or a value from JSON) and need to ask "what are you, really?" That's the is operator. It returns a bool:

Object value = 'hello';

if (value is String) {
  print('It is a string');
}

if (value is! int) {
  print('It is definitely not an int');
}

The magic part is type promotion. Once is proves the type inside an if, Dart promotes the variable — inside that block you can use it as the real type, no cast needed:

Object value = 'hello world';

if (value is String) {
  // inside here, `value` is treated as a String
  print(value.length);          // ✅ allowed now
  print(value.toUpperCase());   // ✅ allowed now
}

Outside the if, it's back to Object. This is the safe, idiomatic way to work with values whose type you don't know up front.


Casting with as

Sometimes you know the type but the compiler doesn't, and you want to assert it directly. That's as:

Object value = 'hello';
String s = value as String; // "trust me, it's a String"
print(s.length);

The catch: if you're wrong, as throws a TypeError at runtime:

Object value = 42;
String s = value as String; // 💥 runtime crash — it was an int

So the rule is simple:

  • Prefer is (with promotion) — it's safe and the compiler protects you.
  • Use as only when you're certain, or when promotion isn't possible (e.g. casting a value you can't put in an if first).
// idiomatic & safe
if (value is String) {
  print(value.length);
}

// only when you're sure
final s = value as String;

Runtime type vs static type

One last distinction that clears up a lot of confusion. A value has two notions of type:

  • its static type — what the compiler thinks it is (from the declaration), and
  • its runtime type — what it actually is when the program runs.

You can inspect the runtime type with .runtimeType:

Object x = 42;
print(x.runtimeType); // int — the real thing
// static type of x is Object; runtime type is int

You rarely need .runtimeType in real code (prefer is for branching — it understands subtypes, runtimeType doesn't). But knowing the two can differ explains why an Object variable can still be an int underneath, and why is can succeed where the static type alone wouldn't tell you.


Practice Challenges

Try each one yourself before peeking. There's usually more than one valid answer.

Challenge 1 — Spot the inferred type. Without running it, what's the type of each variable?

var a = 3 + 4;
var b = 3 / 4;
var c = 'a' * 3;   // careful!
var d = [1, 2.0];
Show solution
var a = 3 + 4;   // int     → 7
var b = 3 / 4;   // double  → / always returns double (see Part 3)
var c = 'a' * 3; // String  → 'aaa' (String * int repeats it)
var d = [1, 2.0];// List<num> → int and double share the parent `num`

The interesting one is d: Dart finds the nearest common type of 1 (int) and 2.0 (double), which is num.

Challenge 2 — Fix the trap. This compiles but is unsafe. Why, and how do you fix it?

var users = [];
users.add('Asha');
Show solution

var users = [] infers List<dynamic> because the empty literal gives nothing to infer from — so the list will silently accept any type. Annotate the element type:

var users = <String>[];   // List<String>
users.add('Asha');
// users.add(42);          // ❌ now caught at compile time

Challenge 3 — Safe extraction. You're given Object data. Print its length only if it's a String, without using as and without risking a crash.

Show solution
void describe(Object data) {
  if (data is String) {
    print(data.length); // promoted to String — safe
  } else {
    print('Not a string');
  }
}

is plus type promotion does it cleanly. No cast, no possible TypeError.

Challenge 4 — Object vs dynamic. One of these is caught at compile time, the other crashes at runtime. Which is which?

Object a = 5;
dynamic b = 5;
print(a.length);
print(b.length);
Show solution
  • a.lengthcompile error. Object has no length; the type checker stops you immediately.
  • b.lengthcompiles, then crashes at runtime with a NoSuchMethodError, because dynamic disables checking and int has no length.

This is the whole argument for preferring Object/Object? over dynamic: bugs surface while you type, not in production.


Check Yourself (Q&A)

Q1. Does var make a variable's type changeable? No. var only asks Dart to infer the type from the initial value; once inferred, it's fixed. dynamic is the one that lets the type change.

Q2. Why is var x; (no value) risky? With no initializer there's nothing to infer from, so x becomes dynamic — you lose type checking. Give it a value on the same line, or write the type explicitly.

Q3. What's the difference between Object and Object?? Object is every non-null value; Object? additionally includes null. Object? is Dart's true top type.

Q4. When should I use as instead of is? Prefer is — it's safe and promotes the variable. Use as only when you're certain of the type or when you can't structure the code as an if (no promotion available). A wrong as throws at runtime.

Q5. Static type vs runtime type? The static type is what the compiler tracks from your declarations; the runtime type is the actual class of the value while running. An Object-typed variable can have a runtime type of int. Use is (not .runtimeType) to branch, because is respects subtyping.


Wrapping Up

  • Inference gives you static safety without the typing — but it needs a value to work from. No value or an empty collection → write the type yourself.
  • var infers and locks; Object/Object? accept anything while staying checked; dynamic switches checking off (avoid it).
  • is tests a type and promotes the variable — your safe default. as asserts a type and crashes if wrong.
  • Every value has a static type (compiler's view) and a runtime type (the real class); they can differ.

Next up in Part 5, we tackle the feature Dart is genuinely famous for — sound null safety: what ?, !, and late really do, and why they kill a whole category of crashes.