validateList static method
Validates each element in raw against expectedTypes, treating non-Map items as failures.
Accepts a List<dynamic> as returned directly by jsonDecode, without
requiring manual casting or filtering before calling validateBatch.
Non-Map<String, dynamic> items at index N produce a JsonValidationResult.failure
with the message "Item N is not a Map<String, dynamic>." and are counted in
BatchValidationResult.failureCount and BatchValidationResult.failureIndices.
Map items are validated against expectedTypes identically to validateBatch.
Use BatchValidationResult.failureIndices to skip invalid items when mapping
raw to model instances:
// Full-or-nothing: return null if any item fails.
static List<UserModel>? tryFromListJson(List<dynamic> raw) {
final BatchValidationResult batch = JsonSentinel.validateList(
raw: raw,
expectedTypes: {'id': [int], 'name': [String]},
context: 'UserModel',
);
if (!batch.isValid) return null;
return [for (final Object? item in raw) if (item is Map<String, dynamic>) UserModel.fromJson(item)];
}
// Skip-bad-items: return only the valid ones.
static List<UserModel> fromValidListJson(List<dynamic> raw) {
final BatchValidationResult batch = JsonSentinel.validateList(
raw: raw,
expectedTypes: {'id': [int], 'name': [String]},
context: 'UserModel',
);
return [
for (int i = 0; i < raw.length; i++)
if (!batch.failureIndices.contains(i) && raw[i] is Map<String, dynamic>)
UserModel.fromJson(raw[i] as Map<String, dynamic>),
];
}
optional, strict, warnUnexpected, validators, escalate, and
generatePreviews behave identically to validateBatch.
When generatePreviews is true, item_previews in the logger extras
contains a JSON preview for Map items and the literal string '[non-Map item]'
for non-Map items.
Implementation
static BatchValidationResult validateList({
required List<dynamic> raw,
required Map<String, List<Type?>?> expectedTypes,
String context = 'UnknownModel',
Set<String> optional = const <String>{},
bool strict = false,
bool warnUnexpected = false,
bool escalate = false,
bool generatePreviews = true,
Map<String, bool Function(Object?)>? validators,
}) {
final StackTrace stackTrace = StackTrace.current;
assert(
!(strict && warnUnexpected),
'JsonSentinel.validateList: strict and warnUnexpected are mutually exclusive — '
'use strict to fail on unexpected keys, or warnUnexpected to log without failing, not both.',
);
final List<JsonValidationResult> results = <JsonValidationResult>[];
final List<List<String>> itemWarnings = <List<String>>[];
for (int i = 0; i < raw.length; i++) {
final Object? item = raw[i];
if (item is Map<String, dynamic>) {
final ({List<String> errors, List<String> warnings}) core = _validateCore(
json: item,
expectedTypes: expectedTypes,
optional: optional,
strict: strict,
warnUnexpected: warnUnexpected,
validators: validators,
);
itemWarnings.add(core.warnings);
results.add(core.errors.isEmpty ? JsonValidationResult.success : JsonValidationResult.failure(core.errors));
} else {
itemWarnings.add(<String>[]);
results.add(JsonValidationResult.failure(<String>['Item $i is not a Map<String, dynamic>.']));
}
}
final BatchValidationResult batch = BatchValidationResult.fromResults(results);
if (batch.failureCount == 0) {
if (_verbose) {
developer.log(
'[$context] validateList() passed — ${raw.length} item(s) checked.',
name: 'JsonSentinel',
);
}
} else {
final String itemsWord = raw.length == 1 ? 'item' : 'items';
final StringBuffer buffer = StringBuffer(
'[$context] JSON list validation failed (${batch.failureCount} of ${raw.length} $itemsWord failed):',
);
for (final int i in batch.failureIndices) {
final List<String> errors = results[i].errors;
if (errors.isEmpty) continue;
final String errWord = errors.length == 1 ? 'error' : 'errors';
buffer.write('\n Item $i (${errors.length} $errWord):');
for (final String e in errors) {
buffer.write('\n • $e');
}
}
_log(
buffer.toString(),
stackTrace: stackTrace,
extras: <String, Object?>{
'context': context,
'failure_count': batch.failureCount,
'total_count': raw.length,
if (generatePreviews)
'item_previews': <String>[
for (final int i in batch.failureIndices)
if (raw[i] is Map<String, dynamic>) _jsonPreview(raw[i] as Map<String, dynamic>) else '[non-Map item]',
],
},
escalate: escalate,
);
if (_verbose) {
developer.log(
'[$context] validateList() failed — ${batch.failureCount} of ${raw.length} item(s) invalid.',
name: 'JsonSentinel',
);
}
}
final List<int> warningIndices = <int>[
for (int i = 0; i < itemWarnings.length; i++)
if (itemWarnings[i].isNotEmpty) i,
];
if (warningIndices.isNotEmpty) {
final String itemsWord = raw.length == 1 ? 'item' : 'items';
final StringBuffer wb = StringBuffer(
'[$context] JSON list validation warning (${warningIndices.length} of ${raw.length} $itemsWord had unexpected keys):',
);
for (final int i in warningIndices) {
final List<String> ws = itemWarnings[i];
final String keyWord = ws.length == 1 ? 'key' : 'keys';
wb.write('\n Item $i (${ws.length} unexpected $keyWord):');
for (final String w in ws) {
wb.write('\n • $w');
}
}
_log(wb.toString(), stackTrace: stackTrace, extras: <String, Object?>{'context': context}, escalate: false);
if (_verbose) {
developer.log(
'[$context] validateList() warned — ${warningIndices.length} of ${raw.length} item(s) had unexpected keys.',
name: 'JsonSentinel',
);
}
}
return batch;
}