Flutter / Asynchronous application boundaries / Lesson 8
Typed models and JSON boundaries
Validate external shapes before constructing models and preserve the distinction between malformed, absent, and valid data.
On this page
Lesson goals
- Decoded JSON is not yet a domain model
- Model meaning and units
- Validate shape before casts
Decoded JSON is not yet a domain model
JSON decoding establishes syntax and produces general Dart values such as maps, lists, numbers, strings, booleans, and null. It does not prove that a field named seats is a nonnegative integer or that every record has a unique identifier. Treat the decoded object as untrusted boundary input, even when a service is maintained by your own team. Service versions, cache contents, and damaged fixtures can violate assumptions. Construct a typed model only after checking the fields your application actually requires, with an explicit policy for missing and unknown fields.
Model meaning and units
An immutable model makes accepted state easier to reason about because a widget can rely on its invariants. Use names such as availableSeats or priceCents rather than a generic value when units matter. Separate transport representation from display formatting: a timestamp string, parsed instant, and localized visible date serve different roles. A model constructor can establish small invariants, while a repository can coordinate relationships across records. Do not let convenience defaults turn a missing price into zero unless the contract explicitly defines that meaning, since a plausible number can conceal a serious data defect.
Validate shape before casts
The first fragment accepts a general decoded object and checks its shape before reading typed fields. Invalid content throws a FormatException with a stable diagnostic description. The model’s accepted range is intentionally narrow for the exercise. An application might instead return a structured decoding failure, but it should retain which boundary rejected the record. Avoid echoing an entire sensitive payload in the error message. Because the returned fields are final and validated, downstream presentation can use them without repeatedly casting dynamic values and hoping the transport stayed consistent.
class Workshop {
const Workshop(this.id, this.seats);
final String id;
final int seats;
factory Workshop.fromJson(Object? raw) {
if (raw is! Map<String, dynamic>) throw const FormatException('Expected object');
final id = raw['id'];
final seats = raw['seats'];
if (id is! String || id.isEmpty) throw const FormatException('Invalid id');
if (seats is! int || seats < 0) throw const FormatException('Invalid seats');
return Workshop(id, seats);
}
}
A valid object becomes Workshop; malformed fields throw the stated FormatException.
Checks establish types and the nonnegative-seat invariant before construction. The fragment assumes JSON map keys have been decoded as strings.
Test values that look almost correct
The second fragment illustrates a valid record and an invalid numeric string. The string 3 is not accepted as the integer 3 because coercion was not part of the chosen contract. If an external service explicitly permits both forms, normalize them at one documented boundary and reject ambiguous values. Do not scatter int.parse calls through widgets, where failure becomes a rendering exception. Also distinguish a missing field from an explicitly nullable optional field. A transport schema can evolve without making every field optional or silently discarding the application’s invariants.
void checkBoundary() {
final valid = Workshop.fromJson({'id': 'w-7', 'seats': 3});
print('${valid.id}:${valid.seats}');
try {
Workshop.fromJson({'id': 'w-8', 'seats': '3'});
} on FormatException catch (error) {
print(error.message);
}
}
w-7:3
Invalid seats
This function depends on Workshop from the preceding fragment. It is not standalone, and its results are reasoned examples.
Choose a collection failure policy
When one record in a response is invalid, decide whether the entire response fails or whether valid records may be shown with an explicit partial-data state. Silently skipping bad records can make totals and pagination misleading. For a catalog where every row is independent, partial display may be useful; for a financial summary, omitting one record may destroy correctness. Duplicate identifiers also belong at the collection boundary because two individually valid models can conflict as a set. Validate consistency before handing a list to keyed widgets or merging it into a cache.
Keep decoding and presentation independently testable
Use small synthetic fixtures for valid, missing, null, wrong-type, out-of-range, and duplicate cases. Assert the application-level failure category rather than depending on an incidental cast exception. Then test presentation with already valid models so a visual failure is not entangled with transport parsing. Code generation can reduce mapping repetition, but generated casts do not decide business invariants or compatibility policy for you. Review generated behavior and keep the source schema, generator configuration, and dependency versions reproducible within the project’s normal workflow.
Common errors and fixes
A widget throws a cast exception for seats
- Why it happens
- Transport values reached rendering without boundary validation.
- How to fix it
- Decode and validate into a model before presentation.
- Prevent it next time
- Test wrong-type fixtures independently of widgets.
Missing data becomes a believable zero
- Why it happens
- A default erased an important absence distinction.
- How to fix it
- Reject absence or model it explicitly according to the schema.
- Prevent it next time
- Document defaults as domain decisions.
Valid individual records produce duplicate row keys
- Why it happens
- Collection-level identity was never checked.
- How to fix it
- Validate uniqueness before publishing the collection.
- Prevent it next time
- Include conflicting identifiers in decoding tests.
Check your understanding
1. Should seats: 3.0 be accepted by the shown decoder?
Show worked solution
No, the chosen contract requires int, while that literal is double. If the service contract allows integral floating-point values, add a documented conversion that checks finiteness, integrality, and range. Do not rely on truncation because 3.9 would silently become 3.
2. A response contains nine valid rows and one malformed row. Is skipping always safe?
Show worked solution
No. Assess whether omission preserves the task’s meaning. A partial catalog may be acceptable when clearly marked; a reconciled total may require rejecting the whole response. Make the policy visible to callers and test it rather than hiding it in a catch block.
Key takeaways
- Decoded JSON is not yet a domain model
- Keep decoding and presentation independently testable