Start Debugging

Решение: type 'Null' is not a subtype of type 'X' в Dart

Эта ошибка времени выполнения означает, что null попал в приведение, ожидавшее тип, не допускающий null, почти всегда из JSON. Сделайте поле допускающим null или задайте значение по умолчанию до приведения.

type 'Null' is not a subtype of type 'X' — это ошибка типа времени выполнения: null попал в место вашего кода, где приведение или присваивание настаивало на типе, не допускающем null. Подавляюще частый источник — разбор JSON, где ключ отсутствует или приходит как null, а вы приводите его напрямую к String, int или типу модели. Решение — не дать приведению увидеть сырой null: либо объявите целевой тип допускающим null (String?) и обработайте null, либо задайте значение по умолчанию через ?? fallback до того, как приведение произойдёт. Это проверено на Dart 3.12 (Flutter 3.44); поведение остаётся тем же в каждом выпуске с надёжным null safety начиная с Dart 2.12.

Ошибка в контексте

Сообщение называет конкретный тип, которым значение должно было быть. При декодировании JSON оно обычно выглядит так:

Unhandled Exception: type 'Null' is not a subtype of type 'String' in type cast
#0      _$UserFromJson (package:myapp/models/user.dart:12:34)
#1      new User.fromJson (package:myapp/models/user.dart:8:7)
#2      fetchUser (package:myapp/api/client.dart:41:24)
<asynchronous suspension>

Два слова в этом сообщении делают всю работу. Первое, 'Null', — это тип, который значение действительно имело во время выполнения: это был null. Второе, после “subtype of type”, — это то, чего требовал код: 'String', 'int', 'List<dynamic>', 'Map<String, dynamic>' или один из ваших собственных классов модели. Завершающее in type cast говорит вам, что сбой произошёл при явном или неявном приведении as, что является характерным отпечатком декодирования нетипизированного dynamic-JSON в типизированные поля.

Вы также увидите вариант без in type cast, например type 'Null' is not a subtype of type 'String', когда значение попадает в параметр или поле, не допускающее null, а не в выражение as. Та же коренная причина, те же решения.

Почему это происходит

При надёжном null safety Null — это собственный тип, и он не является подтипом ни одного типа, не допускающего null. В этом весь смысл null safety: String действительно не может содержать null, поэтому среда выполнения отказывается позволить null выдать себя за него. Когда вы пишете json['name'] as String, а json['name'] равен null, вы просите среду выполнения рассматривать Null как String, и она бросает ошибку.

Причина, по которой это проявляется во время выполнения, а не во время компиляции, в том, что JSON имеет тип dynamic. jsonDecode возвращает dynamic, и каждое обращение к Map<String, dynamic> тоже dynamic. Компилятор не может видеть, что на самом деле лежит в карте, поэтому он доверяет вашему приведению as String и откладывает проверку на время выполнения. Если реальное значение равно null, проверка проваливается в момент выполнения этой строки. Вот почему ошибка так распространена в фабриках fromJson и в коде, сгенерированном json_serializable: это именно те места, где значения dynamic заталкиваются в типизированные формы.

Есть три ситуации, которые её порождают, примерно в порядке частоты:

Минимальное воспроизведение

Наименьший фрагмент, воспроизводящий канонический случай:

// Dart 3.12, Flutter 3.44
import 'dart:convert';

class User {
  final String name;
  final int age;
  User({required this.name, required this.age});

  factory User.fromJson(Map<String, dynamic> json) => User(
        name: json['name'] as String, // throws if 'name' is null or missing
        age: json['age'] as int,
      );
}

void main() {
  // 'name' is absent from the payload
  final payload = jsonDecode('{"age": 30}') as Map<String, dynamic>;
  final user = User.fromJson(payload); // type 'Null' is not a subtype of type 'String'
  print(user.name);
}

json['name'] вычисляется в null, потому что ключа нет в карте. Приведение as String затем пытается увидеть null как String и бросает ошибку. Обратите внимание, что исключение выбрасывается внутри User.fromJson, а не на print, поэтому трассировка стека указывает на ваш файл модели, а не на виджет, который в итоге отобразил данные.

Решение в деталях

Пройдите по ним по порядку. Первые два покрывают почти каждое реальное появление; остальные обрабатывают формы, которые простые решения не покрывают.

1. Сделайте поле допускающим null, если данные действительно могут отсутствовать

Если бэкенд может законно опустить или обнулить значение, смоделируйте это честно. Объявите поле Dart допускающим null и позвольте приведению нацелиться на тип, допускающий null, подтипом которого является null:

// Dart 3.12, Flutter 3.44
class User {
  final String? name; // was String
  final int age;
  User({this.name, required this.age});

  factory User.fromJson(Map<String, dynamic> json) => User(
        name: json['name'] as String?, // as String?, not as String
        age: json['age'] as int,
      );
}

json['name'] as String? успешно проходит, будь значение String или null, потому что Null является подтипом String?. Компромисс в том, что каждый потребитель name теперь должен обрабатывать null, что и есть та корректность, признать которую вас просит система типов. Это правильное решение, когда поле действительно необязательное.

2. Задайте значение по умолчанию через ?? до того, как значение достигнет поля, не допускающего null

Если поле должно оставаться не допускающим null, но вы можете выбрать разумное запасное значение, устраните null до того, как приведение завершится:

// Dart 3.12, Flutter 3.44
factory User.fromJson(Map<String, dynamic> json) => User(
      name: json['name'] as String? ?? 'Unknown', // cast to nullable, then default
      age: json['age'] as int? ?? 0,
    );

Порядок имеет значение. Сначала приведите к типу, допускающему null (as String?), затем примените ??. Если вы напишете json['name'] ?? 'Unknown' as String, приоритет превратит это в json['name'] ?? ('Unknown' as String), что не защищает левую сторону и всё равно бросает ошибку, когда значение — неверный ненулевой тип. Приведение к типу, допускающему null, и последующее слияние — это идиома, которая читается чисто и ведёт себя корректно.

3. Никогда не приводите значение dynamic-карты напрямую к типу, не допускающему null

Привычка, которая вызывает эту ошибку, — json['x'] as ConcreteType. Сделайте безопасную форму своим значением по умолчанию: приведите к типу, допускающему null, затем решите, что означает null. Для вложенных объектов и списков то же правило применяется на уровень глубже:

// Dart 3.12, Flutter 3.44
// A list that may be absent -> default to empty, never null-cast the elements
final tags = (json['tags'] as List<dynamic>?)
        ?.map((e) => e as String)
        .toList() ??
    <String>[];

// A nested object that may be absent -> guard before recursing
final address = json['address'] == null
    ? null
    : Address.fromJson(json['address'] as Map<String, dynamic>);

Приведение внешнего контейнера к List<dynamic>? или проверка == null перед рекурсией не дают null когда-либо достичь элемента или вложенного приведения. Именно здесь написанный вручную код fromJson чаще всего ошибается: поле верхнего уровня защищено, а элементы списка или вложенная карта — нет.

4. Если вы используете json_serializable, пометьте поле как допускающее null или задайте ему значение по умолчанию

Сгенерированный код fromJson приводит ровно так же, как вы делали бы вручную, поэтому поле, не допускающее null, с @JsonKey порождает ту же ошибку времени выполнения, когда данные отсутствуют. Исправьте это в объявлении модели и перегенерируйте:

// Dart 3.12, Flutter 3.44, json_serializable 6.x
@JsonSerializable()
class User {
  final String? name;                       // nullable -> generator emits `as String?`
  @JsonKey(defaultValue: 0) final int age;  // default -> used when the key is null/absent
  User({this.name, required this.age});

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

Поле, допускающее null, заставляет генератор выдать as String?. @JsonKey(defaultValue: ...) заставляет его подставить значение по умолчанию, когда ключ отсутствует или равен null. Запустите dart run build_runner build --delete-conflicting-outputs после изменения аннотаций, иначе выполняется старое сгенерированное приведение.

5. Исправьте несовпадение формы, когда значение на самом деле не null

Если ошибка называет тип вроде 'String', но полезная нагрузка явно содержит значение, значение имеет неверную форму. Бэкенд, отправляющий "age": "30" (строку), когда вы приводите as int, или "30" там, где вы ожидаете число, вызывает то же семейство ошибок. Приводите явно, а не через приведение типов:

// Dart 3.12, Flutter 3.44
// Backend sends age as a string sometimes, an int other times
final age = json['age'] is int
    ? json['age'] as int
    : int.parse(json['age'].toString());

Это не случай Null, но он приводит людей на эту страницу, потому что форма сообщения идентична. Когда тип в сообщении слева не 'Null', смотрите на то, что сервер действительно отправил, а не на вашу обработку null.

Подводные камни и варианты

Ментальная модель, которую стоит унести: эта ошибка — это null safety, отказывающийся позволить null притвориться тем, чем он не является. Значение вошло как null, а некое приведение или присваивание ниже по потоку пообещало, что оно им не будет. Решение никогда не в том, чтобы форсировать приведение сильнее; оно в том, чтобы решить на границе, где данные dynamic входят в ваш типизированный мир, может ли это поле отсутствовать. Если может, сделайте его допускающим null и обработайте null. Если не может, задайте ему значение по умолчанию до того, как приведение завершится. Делайте это при каждом обращении json[...], и эта ошибка перестанет появляться.

Похожее

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад