Решение: 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 заталкиваются в типизированные формы.
Есть три ситуации, которые её порождают, примерно в порядке частоты:
- Ключ JSON полностью отсутствует, поэтому
json['key']возвращаетnull, и вы приводите этотnullк типу, не допускающему null. - Ключ присутствует, но его значение — JSON
null, например допускающий null столбец, сериализованный из бэкенда. - Значение присутствует, но имеет неверную форму, например число приходит как
int, когда вы приводите кString, или объект там, где вы ожидали список. Это бросает другое сообщение о подтипе, но относится к той же категории ошибок.
Минимальное воспроизведение
Наименьший фрагмент, воспроизводящий канонический случай:
// 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.
Подводные камни и варианты
-
Трассировка стека указывает на модель, а не на интерфейс. Поскольку приведение выполняется внутри
fromJson, верхний кадр — это ваш файл модели. Разработчики часто начинают отлаживать виджет, показавший пустое поле; настоящее исправление на один-два кадра ниже, при приведении. Читайте первый кадр в трассировке, не относящийся к фреймворку. -
as String?— это не то же самое, чтоas String. Единственный?— это всё исправление в большинстве случаев.as String?допускаетnull;as Stringего запрещает. Если вы копируете приведение из поля, не допускающего null, в допускающее null, не забудьте добавить?, иначе вы переместили ошибку, а не исправили её. -
Приведения
Map<String, dynamic>проваливаются точно так же.jsonDecodeвозвращаетdynamic. Если вся полезная нагрузка —null(пустое тело ответа 204, например), тогдаjsonDecode(body) as Map<String, dynamic>бросаетtype 'Null' is not a subtype of type 'Map<String, dynamic>'ещё до того, как вы доберётесь до поля. Защитите декодирование:body.isEmpty ? null : jsonDecode(body). Это пересекается с некорректным вводом, разбираемым в FormatException: Unexpected character при разборе JSON в Dart. -
Оператор bang перемещает сбой, а не исправляет его. Написание
json['name']!превращает ошибку подтипаNullвNull check operator used on a null value. Это тот же лежащий в основе null, брошенный другим механизмом. См. Null check operator used on a null value в Flutter, чтобы понять, почему!следует приберечь для значений, которые вы можете доказать как ненулевые, а не использовать для того, чтобы заглушить компилятор, с которым вы не согласны. -
Поля
lateпревращают тот же null в другую ошибку. Если вы направите потенциально нулевое значение в полеlate, не допускающее null, и прочитаете его до присваивания, вы получитеLateInitializationError. Лекарство то же: смоделируйте отсутствие честно, а не обещайте значение, которого у вас нет. См. LateInitializationError: Field has not been initialized в Flutter. -
Аргументы обобщённого типа скрывают приведение.
List<String>.from(json['tags'])и(json['tags'] as List).cast<String>()оба бросают эту ошибку лениво, когда элемент равенnull, и трассировка может указывать на.cast, а не на ваш код. Предпочитайте явный.map((e) => e as String?), чтобы сбой был виден и был вашим для обработки. -
Это ошибка времени выполнения, поэтому тесты её ловят, а анализатор нет. Анализатор не может заглянуть внутрь
dynamic-JSON-карты, поэтомуdart analyzeостаётся зелёным, пока приведение небезопасно. Один модульный тестfromJsonс полезной нагрузкой, опускающей поле, выносит ошибку на поверхность прежде, чем это сделает пользователь. Если вы мигрируете более старую кодовую базу, чек-лист миграции на null safety во Flutter проходит по тому, где эти приведения обычно прячутся.
Ментальная модель, которую стоит унести: эта ошибка — это null safety, отказывающийся позволить null притвориться тем, чем он не является. Значение вошло как null, а некое приведение или присваивание ниже по потоку пообещало, что оно им не будет. Решение никогда не в том, чтобы форсировать приведение сильнее; оно в том, чтобы решить на границе, где данные dynamic входят в ваш типизированный мир, может ли это поле отсутствовать. Если может, сделайте его допускающим null и обработайте null. Если не может, задайте ему значение по умолчанию до того, как приведение завершится. Делайте это при каждом обращении json[...], и эта ошибка перестанет появляться.
Похожее
- Решение: FormatException: Unexpected character при разборе JSON в Dart для родственной ошибки, когда полезная нагрузка даже не является допустимым JSON.
- Решение: Null check operator used on a null value в Flutter о том, что происходит, когда вы прибегаете к
!, чтобы вместо этого заглушить это приведение. - Решение: LateInitializationError: Field has not been initialized в Flutter для варианта
lateс обещанием значения, которого у вас нет. - Миграция приложения Flutter 2 на Flutter 3.x: чек-лист null safety для поиска этих небезопасных приведений по всей кодовой базе.
Источники
- Dart, Understanding null safety (почему
Nullбольше не является подтипом каждого типа и почему неявные понижающие приведения изdynamicстали явными приведениями, которые проваливаются во время выполнения). - Dart, Sound null safety (гарантии, заставляющие
String, не допускающий null, отвергатьnullво время выполнения). - GitHub, dart-lang/sdk issue #53700 (“type ‘Null’ is not a subtype of type ‘String’”, сообщённая против реального кода декодирования JSON, с коренной причиной отсутствующего ключа).
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.