Dart / Integration: targets, resources, validation, and evidence / Lesson 17
Turn decoded JSON into validated local models
Separate decoding, structural checks, domain validation, explicit field selection, and safe rejection of untrusted data.
On this page
Lesson goals
- Explain why valid JSON and dynamic map access do not establish a trusted request.
- Validate every required field and preserve a deliberate policy for normalization, unknown data, and partial acceptance.
Decoding establishes syntax, not the application contract
jsonDecode turns JSON text into Dart values such as maps, lists, strings, numbers, booleans, and null. A successfully decoded value can still have the wrong top-level shape or unacceptable field values. Treat the result as untrusted input rather than allowing dynamic access throughout domain code. Separate malformed JSON from structurally invalid data and business rejection when callers need different explanations. An identifier being a String does not prove it exists or belongs to the caller. A count being an int does not prove it falls within the application’s permitted range.
Construct a trusted object only after all required checks
The validator accepts an Object? boundary value and checks map shape and field types explicitly. It trims the identifier and allows counts from one to one thousand under an invented lab policy, not a universal service limit. A nullable result keeps the sample compact; a real API may need structured reasons for missing fields, wrong types, and out-of-range values. The class is trusted only for the checks shown. It does not authorize the identifier or establish persistence, and callers must not generalize its narrow validation into a complete security or booking guarantee.
import 'dart:convert';
class BatchRequest {
final String id;
final int count;
BatchRequest(this.id, this.count);
}
BatchRequest? validateRequest(Object? value) {
if (value is! Map<String, dynamic>) return null;
final id = value['id'];
final count = value['count'];
if (id is! String || count is! int) return null;
final normalized = id.trim();
if (normalized.isEmpty || count < 1 || count > 1000) return null;
return BatchRequest(normalized, count);
}
void main() {
final Object? value = jsonDecode('{"id":" B-2 ","count":3}');
final request = validateRequest(value);
print(request == null ? 'invalid' : '${request.id}:${request.count}');
print(validateRequest(<String, Object?>{'id': 'B-3', 'count': '3'}) == null);
}
B-2:3
true
Expected output is B-2:3 then true. The string count is rejected rather than parsed implicitly. The source uses fixed JSON and local objects and was not executed.
Make normalization and unknown-field rules visible
Trimming can be appropriate for a human-entered lab code, but not automatically for every signed token or protocol identifier. Decide whether extra fields are ignored, rejected, or retained in a bounded extension area. Duplicate member-name policy may need handling before an ordinary decoded map has already collapsed the source representation. Likewise, a null field and a missing field may require separate treatment. These choices belong to the public data contract. Do not silently delete invalid list elements and then report complete success unless partial acceptance is the declared product behavior.
Encode an explicit output contract instead of an entire object
Select the fields that belong in an external representation rather than serializing every convenient internal property. The example creates a small public map and omits an internal note. This is field selection, not an authorization check or a complete privacy policy. Use jsonEncode to escape strings correctly instead of assembling JSON by concatenation. Numeric precision and supported values still depend on the data model and target; avoid assuming an arbitrarily large integer round-trips identically through every JavaScript consumer. Stable field meaning matters as much as syntactically valid output.
import 'dart:convert';
void main() {
final internal = <String, Object?>{
'id': 'B-1',
'count': 2,
'internalNote': 'lab-only',
};
final publicValue = <String, Object?>{
'id': internal['id'],
'count': internal['count'],
};
print(jsonEncode(publicValue));
}
{"id":"B-1","count":2}
Expected output is {"id":"B-1","count":2}. The internalNote field is omitted intentionally. No external data or service is accessed, and no encoding test was executed.
Bound input before trusting a convenient decoder
A decoder does not automatically impose the product’s input-size, nesting, list-length, or processing limits. Enforce appropriate bounds at the receiving layer and avoid logging whole rejected payloads. A malformed-syntax handler should catch the relevant FormatException without hiding unrelated application defects. Test wrong top-level types, missing and null fields, mixed lists, boundary numbers, and the normalization policy in a compatible isolated project. Here, JSON fixtures can be checked as data, but Dart decoding, runtime promotion, and target-specific numeric behavior were not executed; outputs remain reasoned expectations.
Common errors and fixes
Valid JSON is accepted despite wrong field types.
- Why it happens
- Syntax decoding was mistaken for schema and domain validation.
- How to fix it
- Validate top-level shape, required field types, and semantic ranges explicitly.
- Prevent it next time
- Keep decoded values untrusted until a validated model is constructed.
An internal field appears in the public payload.
- Why it happens
- The encoder serialized convenience state instead of a selected output contract.
- How to fix it
- Build an explicit representation containing only declared public fields.
- Prevent it next time
- Review serialization alongside authorization, logging, and data-minimization needs.
An invalid count becomes zero and is reported as success.
- Why it happens
- A fallback concealed the distinction between malformed data and a permitted value.
- How to fix it
- Return explicit validation failure or structured partial results.
- Prevent it next time
- Require a declared policy before collapsing invalid input into defaults.
Check your understanding
1. Should count: "3" be automatically treated as count: 3?
Show worked solution
Only if the public contract deliberately permits that coercion and specifies its exact grammar, ranges, and error behavior. Otherwise reject the type mismatch. Silent coercion can hide sender defects and create inconsistent behavior across clients. Keep syntactic conversion separate from validation of the resulting count.
2. Why does validating id and count not authorize a real batch operation?
Show worked solution
It establishes only the shown structure and value rules. The caller’s identity, resource ownership, allowed action, current state, and concurrency invariant remain separate checks at the authoritative owner. A well-formed request can still be forbidden or stale. Do not let a local DTO constructor become an accidental substitute for resource authorization.
Key takeaways
- Decode, validate, authorize, and execute are distinct boundaries.
- Explicit field and rejection policies preserve meaning across JSON interfaces.