skip to content

A Dart model assigns a decoded JSON array to a List<String> field; it compiles but throws at run time. Why, and how do you stop dynamic type arguments leaking?

level: seniorimportance: should knowfreq 36%

answer

  1. the decoder builds List<dynamic>
  2. implicit downcast from dynamic
  3. reified type argument fails the check
  4. cast, from, or pattern matching
  5. no_raw_types, no_dynamic_casts, Object?

basics

~20 s

jsonDecode returns dynamic, so the assignment is an unchecked-at-compile-time downcast, and the decoded array is a List<dynamic> at run time. A List<dynamic> is not a List<String>, so it throws a TypeError even when every element is a String.

solid answer

~40 s

`jsonDecode` has return type `dynamic`, and the arrays it builds are `List<dynamic>` objects. Assigning a `dynamic` expression to a `List<String>` is an **implicit downcast**: the analyzer allows it and Dart checks it at run time. The check compares the object's reified type, `List<dynamic>`, with `List<String>` and fails, whatever the elements are. Convert explicitly instead: `(json['tags'] as List).cast<String>()` gives a checked view, `List<String>.from(json['tags'] as List)` copies and checks every element now, and a pattern such as `case {'tags': List<Object?> tags}` validates the shape. Prefer `Object?` over `dynamic` for "anything" type arguments, because `List<Object?>` forces explicit casts. To find leaks, Dart 3.13 adds the `no_dynamic_casts` and `no_raw_types` lints, replacing the `strict-casts` and `strict-raw-types` options, and `strict-inference` reports inference falling back to `dynamic`.

code

dart · 20 lines
dart
import 'dart:convert';

class Post {
  Post(this.tags);
  final List<String> tags;

  factory Post.fromJson(Map<String, Object?> json) {
    if (json case {'tags': List<Object?> raw}) {
      // Copies and checks every element now, at the boundary.
      return Post(List<String>.from(raw));
    }
    throw const FormatException('tags missing or not a list');
  }
}

void main() {
  final decoded = jsonDecode('{"tags": ["dart", "flutter"]}');
  final post = Post.fromJson(decoded as Map<String, Object?>);
  print(post.tags.first.toUpperCase()); // DART
}

go deeper

for a junior

Remember that data from jsonDecode is dynamic and its lists are List<dynamic>, so they need an explicit conversion before you treat them as a List<String>.

for a middle

Explain the two mechanisms: an implicit downcast from dynamic is checked at run time, and the check uses the list's reified type argument, not its elements.

for a senior

Show a boundary strategy: convert eagerly with List.from or patterns, prefer Object? to dynamic, and turn on no_dynamic_casts, no_raw_types and strict-inference to find every leak.

for a principal

Set the policy for where untyped data may exist in the codebase, and weigh generated fromJson code against hand-written pattern-based parsing for the whole team.

## What actually fails `jsonDecode` from `dart:convert` is declared as returning **`dynamic`**. On the Dart VM, the decoder builds each JSON array as a **`List<dynamic>`** and each object as a **`Map<String, dynamic>`**. Now look at a typical model: ```dart class Post { Post(this.tags); final List<String> tags; } final json = jsonDecode('{"tags": ["dart", "flutter"]}'); final post = Post(json['tags']); // compiles; throws TypeError ``` Two separate facts combine: 1. **Implicit downcast from `dynamic`.** An expression of static type `dynamic` may be assigned to any type. The analyzer accepts it and Dart inserts a runtime check. 2. **Reified type arguments.** The runtime object remembers that it is a `List<dynamic>`. The check asks "is this object a `List<String>`?" and the answer is no, because `List<dynamic>` is not a subtype of `List<String>` — the element values are never consulted. So the program throws a **`TypeError`** at the constructor call, even though both elements are strings. The same thing happens with nested maps, `Map<String, dynamic>` assigned to `Map<String, int>`, and any other container that came from untyped data. ## Converting safely | Approach | What you get | When the check happens | |---|---|---| | `(raw as List).cast<String>()` | a `List<String>` **view** over the original | each element, when it is read | | `List<String>.from(raw as List)` | a **new** `List<String>` | every element, immediately | | `[for (final t in raw as List) t as String]` | a new list, explicit per-element casts | immediately | | pattern `case {'tags': List<Object?> tags}` | shape validated, then convert | at the match | A view is cheap but postpones failures to wherever the list is read; a copy fails early at the boundary, which is usually what you want for data coming off the network. Generated `fromJson` code does the equivalent conversion for you; how that code is produced is owned by the serialization and code-generation topics. ## `dynamic` versus `Object?` as a type argument Both are **top types**: at run time a `List<dynamic>` and a `List<Object?>` accept exactly the same values. The difference is what the **analyzer** lets you do with an element: - With `List<dynamic>`, `items.first.length` compiles and is a dynamic call that can fail at run time, and `String s = items.first;` compiles as an implicit downcast. - With `List<Object?>`, both lines are compile errors; you must write `items.first as String` or match a pattern. So when a type argument genuinely means "anything", write **`Object?`**: it keeps the flexibility and turns silent runtime failures into visible casts. ## Where inference hands you `dynamic` Beyond JSON, `dynamic` type arguments creep in when inference has nothing to work with: - an **empty literal** with no context: `var cache = {};` is `Map<dynamic, dynamic>`; - a **raw type** annotation: `List items = [...]` uses each type parameter's bound, which for `List` is `dynamic`, so the literal becomes `List<dynamic>`; - a **generic call** with no constraints, such as `var x = wrapper.doSomething();` for `T doSomething<T>()`; - a **raw type nested in a type argument**: `Completer<Map>()` is a `Completer<Map<dynamic, dynamic>>`, because Dart does not use the surrounding context to complete a type you wrote without its arguments; - untyped parameters and return types on members, which default to `dynamic`. Effective Dart draws the line clearly. It is fine to let `dynamic` **propagate** from an API that really is dynamic — `var users = json['users'];` after `json` is typed `Map<String, dynamic>` — but not to let inference **inject** `dynamic` where your code never asked for it. And when you want "any object" rather than "turn off checking", the guidance is `Object?` (or `Object` if null is not allowed), with JSON's `Map<String, dynamic>` as the main exception you accept at the boundary and then cast away from. ## Making the analyzer catch it The analyzer can report every one of these leaks; how you enable rules in `analysis_options.yaml` belongs to the analyzer setup topic. The rules that matter here: - **`no_dynamic_casts`** — flags implicit casts from `dynamic`, such as `Post(json['tags'])`. Added in Dart 3.13, replacing the `strict-casts` option. - **`no_raw_types`** — flags `List items` and other generic types written without type arguments. Added in Dart 3.13, replacing the `strict-raw-types` option. - **`strict-inference`** — reports places where inference silently chooses `dynamic`, such as an untyped empty literal. - **`avoid_dynamic_calls`** — flags member access on `dynamic` targets. With these on, the model above fails analysis at the constructor call and you are pushed toward an explicit, checked conversion at the boundary where untyped data enters the app.

  • Why does `decoded as Map<String, Object?>` succeed when the decoder built a `Map<String, dynamic>`?
    `dynamic` and `Object?` are both top types, so at run time `Map<String, dynamic>` is a subtype of `Map<String, Object?>` and the other way round. The cast only changes what the analyzer lets you do with the values: with `Object?` every use needs an explicit cast or pattern.
  • When is `cast<String>()` the wrong choice for network data?
    When you want to reject bad data at the boundary. `cast<String>()` returns a lazy view that checks each element only when it is read, so a stray number in the array throws later, deep in UI or business code. `List<String>.from` or an explicit loop checks everything once, where the data enters.

saying these in an interview costs you the question

  • Blames a non-String element when every element is a String
  • Thinks the assignment fails at compile time
  • Believes List<dynamic> is a subtype of List<String> at run time
  • Uses dynamic instead of Object? for anything-typed containers by habit
  • Assumes a raw List annotation makes Dart infer the element type