I'm Dmytro Ivanitsky, a senior developer shipping Flutter since its alpha days. Across 15+ production projects — from eSIM apps to retail payment flows — one specific crash kept billing me hours. I finally packaged the structural fix into json_shield. This post covers its core design decisions; the next one in the series covers making it AI-ready for the assistants that increasingly write the code calling it.
The pain
Every Flutter developer knows this crash report line:
type 'List<dynamic>' is not a subtype of type 'Map<String, dynamic>'
And knows what it doesn't say: which of your 30 endpoints broke, what the backend actually sent, or whether it's one corrupt row or the whole schema. Release stack traces are obfuscated; async gaps shred the logical chain. You end up reproducing production traffic locally to learn that the backend wrapped a plain array into a pagination envelope on Tuesday.
The root cause is structural: fromJson factories (quicktype, json_serializable, hand-written — all of them) are chains of as casts. They're supposed to be brittle; that's the contract check. What's missing is attribution when they fail.
What the same break looks like guarded
The backend renames zip to zipCode three levels deep, inside order.customer.address. Unguarded, that's a bare type 'Null' is not a subtype of type 'String' from somewhere. Guarded:
final order = guard.decode(Order.fromJson, response.data,
context: 'orders_byId_get');
DecodeException: [orders_byId_get] expected Order, got _Map<String, dynamic>.
cause: type 'Null' is not a subtype of type 'String'
rawData: {
"id": "ord_12345",
"customer": {
"name": "John Doe",
"address": {
"city": "New York",
"zipCode": "10001"
}
}
}
causeTrace:
#0 new Address.fromJson (package:app/models/address.dart:24:36)
#1 new Customer.fromJson (package:app/models/customer.dart:18:24)
#2 new Order.fromJson (package:app/models/order.dart:12:22)
#3 guard.decode (package:json_shield/json_shield.dart:85:14)
One report line answers the triage questions in order: which endpoint (orders_byId_get), which contract (Order on a body that is a map — so the shape is fine, a field inside broke), what kind of break (a String field came back Null). The preserved causeTrace points at the exact line inside Address.fromJson — the nested model three levels down — and in a debug run with guard.verbose = true the exception carries the raw payload — and the failure line above lands in the console by itself, without a breakpoint — the renamed key visible on sight. Ticket to the backend team written in one glance, no local reproduction of production traffic.
Design constraints
The package is engineered specifically for rapid development. To guarantee high iteration velocity and zero integration friction, these four requirements were fixed before any code was written:
-
Generated models stay untouched. Rapid development means schemas change often. While tools like
quicktype.ioare highly prevalent in these workflows, the package supports any serializer exposing afromJsonmethod. Because generated output is overwritten constantly, any manual additions die on the next run: no annotations, no base classes, no secondary code generation. -
One operator at the call site. Speed requires minimal setup. Not a custom HTTP client, not a configuration object, not a builder — just a single function you wrap around the
fromJsoncall you already have. - Every failure carries its source. Ambiguous crashes halt development; triage must be instantaneous. The endpoint name, expected type, original exception, and exact stack trace are packed into one typed object.
- Zero dependencies. Version conflicts stall builds. The core must be completely isolated: pure Dart, working identically across Flutter, CLI, and server code.
The API
The whole public surface:
import 'package:json_shield/json_shield.dart';
// Single object. T is inferred from the factory tear-off.
final user = guard.decode(User.fromJson, response.data,
context: 'users_me_get');
// Top-level array.
final orders = guard.decodeList(Order.fromJson, response.data,
context: 'orders_list');
Failure handling:
try {
final orders = guard.decodeList(Order.fromJson, response.data,
context: 'orders_list');
} on DecodeListException catch (e) {
// e.total = 100, e.errors = [(index: 17, error: ...), ...]
report(e); // "3/100 failed. #17: ...; #41: ...; #98: ..."
} on DecodeException catch (e) {
// Broken array shape (envelope, null body) or single-object failure.
report(e); // e.context, e.expected, e.cause, e.causeTrace
}
That's it: two methods, two exception types, one flag. One file, ~200 lines, zero dependencies. pub.dev · GitHub
Decisions worth defending
Four choices look wrong at first glance. Each is deliberate.
Shape checks run before your factory
null body, a List where an object is expected, a primitive, a pagination envelope on an array endpoint — all rejected with DecodeException(expected: ...) before fromJson is ever invoked. Your factory only sees a Map<String, dynamic>; the cryptic subtype error class of crashes is gone, replaced by a named expectation. If the factory itself throws — age came back as a string — the exception is wrapped with the original cause and its stack trace preserved, not swallowed.
Lists are traversed fully, failures aggregated
Fail-fast decoding reports element #0, you fix it, deploy, and meet element #41. decodeList never stops: it decodes every element, collects every failure with its index, and throws one DecodeListException carrying all of them. One run yields the complete map of a broken contract — and the pattern inside it. Identical cause on every index? The backend changed the schema. Three scattered indices? Dirty rows in their database. That distinction is a one-glance read in the aggregate and invisible in fail-fast.
The deliberate flip side: it still throws on a single bad element. Silently skipping broken rows and rendering the rest hides data loss from you and your users; if you want partial rendering, catch the aggregate and decide — the decoder won't decide for you.
Payloads are redacted by default
DecodeException.rawData — the actual JSON that failed — is null and the console stays silent unless you flip guard.verbose = true in a debug entry point; with the flag on, every failure is also printed the moment it happens, one line per broken element. Backwards from every logging library, deliberate here: response payloads contain user data, and exceptions flow into crash reporters. After years of wiring gateways into commerce apps, payloads carrying card and customer data are not a hypothetical. The failure mode of forgetting the flag had to be "less diagnostic detail," never "PII in Sentry."
The API shrank during development
Earlier iterations had one/many combinator functions, a unified reified-generics dispatcher, string-input variants, tryDecode result types, and an encode side. All cut. Every additional entry point is another decision at the call site and another way to hold the tool wrong; the two survivors cover the actual production need — decoding a response you just received. The single least-obvious survivor is the argument order, (fromJson, json): factory first reads as "decode as User this data" and keeps the tear-off — the thing that determines the type — in the position your eye checks first.
What it refuses to do
No HTTP, no retries, no schema validation, no code generation. A decoding guard that grows a network client stops being auditable in one sitting — and being auditable in one sitting is a feature: the entire implementation is a single file you can read before trusting it with production traffic.
Next in the series
A growing share of the code calling any package is written by AI assistants that have never seen its repository. Part 2 covers what that changes for package authors: the four artifacts that reach a consumer's AI, the one that steers a contributor's agent, and how to write both so models stop hallucinating your API.
json_shield: pub.dev · GitHub. Issues and API roasting welcome — the surface is frozen pre-1.0, but frozen by discussion, not by ego.
Top comments (0)