Blog
12

type 'List<dynamic>' is not a subtype of type 'List<String>' — Dart's Most Googled Runtime Error

Dart List<dynamic> is not a subtype of List<String> — Why It Happens and How to Fix It

March 26, 2026

The app compiles. The tests pass. The API returns exactly the data you expected. And then, on the first real request, the screen goes red:

javascript
type 'List<dynamic>' is not a subtype of type 'List<String>'

You stare at it. You check the API response — it's an array of strings. You check your model — it expects a List<String>. The types match. Dart disagrees.

This error is one of the most searched Dart runtime errors for a reason: it happens at the boundary between untyped data and typed code, which is exactly where every app that talks to an API lives. Understanding why it happens teaches you something fundamental about how Dart's type system handles generics — and why clean architecture's layered boundaries are the right place to catch it.

What Dart Sees vs. What You See

When you decode a JSON string, Dart gives you a Map<String, dynamic>. The dynamic is the key word. Dart doesn't know what types the JSON values are — it can't, because JSON has no type information beyond string, number, boolean, array, object, and null.

So when you write:

dart
final json = jsonDecode('{"tags": ["flutter", "dart", "mobile"]}');

The type of json['tags'] is not List<String>. It's List<dynamic>. Dart knows it's a list. It knows the elements happen to be strings at runtime. But the static type — the type the compiler tracks — is List<dynamic>.

Now you try to assign it:

dart
final List<String> tags = json['tags']; // ← runtime error runtime error

List<dynamic> is not a subtype of List<String>. Even though every element is a string, the list itself is typed as "a list of anything." Dart's type system won't implicitly narrow that to "a list of strings" because it can't guarantee the list won't later contain a non-string element.

This is Dart being correct. Annoyingly, provably correct.

Where This Hits in Practice

1. Model fromJson Factories

The most common location. You're mapping JSON to a domain model:

dart
class ClientLead {
  final String name;
  final List<String> tags;

  ClientLead({required this.name, required this.tags});

  factory ClientLead.fromJson(Map<String, dynamic> json) {
    return ClientLead(
      name: json['name'],
      tags: json['tags'], // ← runtime error List<dynamic>, not List<String>
    );
  }
}

2. Nested Lists in API Responses

dart
// API returns: { "data": [{"id": 1}, {"id": 2}] }
final List<Map<String, dynamic>> items = json['data']; // ← runtime error
// Dart sees: List<dynamic>, not List<Map<String, dynamic>>

3. List Operations That Preserve dynamic

dart
final names = json['users'].map((u) => u['name']); // Iterable<dynamic>
final List<String> nameList = names.toList(); // ← runtime error

The .map() on a List<dynamic> returns Iterable<dynamic>, regardless of what the lambda actually returns. Dart infers the return type from the input type, not the lambda body.

The Fixes

There are several approaches, each with different tradeoffs:

List<String>.from() — Explicit Construction

dart
tags: List<String>.from(json['tags']),

Creates a new List<String> by iterating the original list and casting each element. If any element is not a string, this throws — which is what you want. Fail loudly at the deserialization boundary rather than silently passing bad data into your domain.

.cast<String>() — Lazy Casting

dart
tags: (json['tags'] as List).cast<String>(),

Returns a lazy view that casts elements as they're accessed. Slightly more efficient if you don't iterate the whole list immediately, but errors surface later — when you access the bad element, not when you deserialize. Generally less safe.

Explicit .map() With Type Annotation

dart
tags: (json['tags'] as List).map((e) => e as String).toList(),

Verbose, but makes every cast explicit. Useful when the transformation isn't just a cast — for example, when converting nested objects:

dart
leads: (json['leads'] as List)
    .map((e) => ClientLead.fromJson(e as Map<String, dynamic>))
    .toList(),

The Pattern That Prevents It

dart
factory ClientLead.fromJson(Map<String, dynamic> json) {
  return ClientLead(
    name: json['name'] as String,
    tags: List<String>.from(json['tags'] ?? []),
    leads: (json['leads'] as List? ?? [])
        .map((e) => ClientLead.fromJson(e as Map<String, dynamic>))
        .toList(),
  );
}

Every field is explicitly cast. Every list is explicitly constructed with its target type. Null cases are handled with ?? []. Nothing relies on Dart inferring the right type from dynamic.

Why This Is a Data Layer Responsibility

In clean architecture, JSON deserialization lives in the data layer — specifically in your data models or DTOs (Data Transfer Objects). The domain layer works with typed entities that know nothing about JSON. The data layer's job is to convert between the two.

This error surfaces when the data layer fails to fully type-convert the JSON response before handing it to the domain. Consider this structure:

javascript
API Response (untyped JSON)
    ↓
Data Layer: ClientLeadDto.fromJson()  ← conversion happens here
    ↓
Domain Layer: ClientLead entity       ← expects fully typed data
    ↓
Presentation: BLoC state             ← trusts the types

If fromJson passes json['tags'] straight through without casting, the List<dynamic> leaks into the domain layer. The domain model's type annotation says List<String>, but the actual runtime type is List<dynamic>. Dart's runtime type check catches the mismatch — but only when something tries to use the list as a List<String>.

The insidious version: the error doesn't always trigger immediately. If you store the list and only read it later — say, when the user opens a detail screen — the crash happens far from the code that caused it. You're debugging the detail screen when the real bug is in the DTO factory three layers away.

The Repository as a Type Boundary

This is why the repository pattern matters. The repository interface lives in the domain layer and returns typed entities:

dart
// Domain layer — no knowledge of JSON:
abstract class ClientLeadRepository {
  Future<List<ClientLead>> searchLeads(String query);
}

The implementation lives in the data layer and handles the conversion:

dart
// Data layer — handles JSON reality:
class ClientLeadRepositoryImpl implements ClientLeadRepository {
  @override
  Future<List<ClientLead>> searchLeads(String query) async {
    final response = await httpClient.get('/api/leads?q=$query');
    final List<dynamic> jsonList = response.data['leads'];
    return jsonList
        .map((json) => ClientLeadDto.fromJson(json).toDomain())
        .toList();
  }
}

The .toDomain() method on the DTO is where every List<dynamic> becomes a List<String>, every Map<String, dynamic> becomes a typed object, and every dynamic field gets explicitly cast. If anything is wrong, it fails here — in the data layer — not in the BLoC, not in the UI, not in a BlocBuilder three screens deep.

Key Insights

`dynamic` is a boundary type, not an interior type. It exists to represent data that hasn't been typed yet — raw JSON, platform channel responses, untyped plugin callbacks. Every dynamic in your code is a potential runtime error waiting to happen. The goal is to eliminate dynamic as early as possible, ideally in the outermost layer of your architecture.

Dart's generic types are reified, but inference from `dynamic` is limited. Unlike Java, Dart retains generic type information at runtime — List<String> and List<int> are genuinely different types. But when the source is dynamic, Dart can't infer what the generic parameter should be. You have to tell it explicitly. This isn't a limitation of the type system — it's a consequence of the fact that JSON has no type information to infer from.

Fail at the boundary, not at the point of use. A List<dynamic> that leaks into your domain layer is a deferred crash. It might surface immediately or it might surface when a user opens a rarely-visited screen two weeks after deployment. Explicit casts in fromJson ensure that bad data crashes at deserialization time, when the API response is still in your logs and the context is fresh.

Code generation eliminates this entire category. Packages like json_serializable or Freezed with json_serializable generate fromJson factories that handle every cast correctly. You define the types, the generator writes the conversion code, and List<dynamic> never leaks past the data layer. The tradeoff is build-time overhead and generated code to maintain — but for projects with more than a handful of models, it pays for itself immediately.

Related Topics

type list dynamic is not a subtype of type list stringdart json list castdart list dynamic to list stringflutter json deserialization errordart fromJson list castdart generic type runtime errorflutter api response type errorList<String>.from dartdart dynamic castflutter clean architecture data layer

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.