safe_json_cast
Typed casts for decoded JSON that fail loudly and name the field, so a
malformed payload surfaces at the parse site instead of as a NaN in a chart
or a blank row three screens later.
No code generation, no build step, no dependencies. Pure Dart, so it works in Flutter apps, server code, CLIs and shared domain packages alike.
factory Ticker.fromJson(Map<String, dynamic> json) => Ticker(
symbol: json.asString('symbol'),
lastPrice: json.asDouble('lastPrice'), // accepts "64,120.55"
tradable: json.asBool('tradable'), // accepts 1, "yes", true
updatedAt: json.asDateTime('updateTime'), // accepts epoch ms or ISO 8601
note: json.asNullableString('note'), // absent or null -> null
);
Why
json['lastPrice'] as double throws a TypeError that names neither the field
nor the value, and the moment the server sends "64120.55" as a string it
throws at all. The usual retreat is (json['lastPrice'] as num?)?.toDouble() ?? 0,
which silently substitutes a zero and pushes the bug into the UI, where it
reads as a rendering fault rather than a parse one.
These casts do neither. Each one takes the field's name alongside its value and
throws a FormatException that quotes both:
FormatException: Field "lastPrice"="n/a" cannot be parsed as double.
That message names the contract that broke, which is usually enough to file the bug against the right side of it.
Install
dependencies:
safe_json_cast: ^1.1.0
Requires Dart 2.15.0 or newer — Flutter 2.8 or newer, if you are on Flutter.
Null safety is required; nothing here needs Dart 3.
There is no flutter constraint in pubspec.yaml, so the package still
resolves in server and CLI projects with no Flutter SDK installed.
The casts
Every cast comes in two forms: a strict one that throws on null, and an
asNullable… one that maps null to null.
| Cast | Accepts |
|---|---|
asString |
any non-null value; anything but a String is rendered with toString() |
asStrictString |
a String and nothing else — no coercion |
asNonEmptyString |
as asString, rejecting one that is empty once trimmed |
asCleanUrl |
as asString, with one leading and one trailing / removed |
asDouble |
any num; a decimal String, with grouping commas and whitespace stripped |
asInt |
any num, truncated toward zero; a numeric String, likewise |
asBool |
a bool; a num, true when non-zero; true/t/1/yes/y and false/f/0/no/n |
asDateTime |
a DateTime; an ISO 8601 String; a num epoch, in seconds or milliseconds |
asNum |
any num, subtype intact; a numeric String |
asBigInt |
an integral num; an integer String — a fraction is rejected, not truncated |
asUri |
a Uri; a parseable String |
asEnum<T> |
a String matching an enum name case-insensitively, or a wireNames key |
asDuration |
a Duration; a num or numeric String, read in a unit you state |
asMap |
a Map whose keys are all Strings |
asMapOf<T> |
as asMap, with each value converted by a callback |
asList<T> |
a List, with each element converted by a callback |
asSet<T> |
as asList, collapsing repeats — or rejecting them |
The string forms are the point of the exercise. Exchange and payment APIs send
prices as strings to preserve precision, booleans as 0/1, and timestamps as
epoch integers — often all three in one payload, and not always consistently
between endpoints.
Two ways to call
The bare functions take the value and its field name:
asDouble(json['lastPrice'], field: 'lastPrice');
The SafeJsonMap extension reads off the map and uses the key as the field
name, which removes the second mention:
json.asDouble('lastPrice');
Prefer the extension for whole-object parsing. Reach for the bare functions when the value did not come from a map — an element inside a list, or a field whose reported name should differ from its key.
Lists
asList converts each element with a callback that receives an indexed
field name, so a failure inside a list says which entry broke:
json.asList(
'changeLogs',
element: (raw, field) => ChangeLog.fromJson(asMap(raw, field: field)),
);
Build the child's field name from the one you are given to carry the whole path into the message:
json.asList(
'levels',
element: (raw, field) =>
asDouble(asMap(raw, field: field)['price'], field: '$field.price'),
);
// FormatException: Field "levels[1].price"="oops" cannot be parsed as double.
asStringList, asIntList, asDoubleList and asMapList are shorthands for
the common cases. Lists come back fixed-length; pass growable: true when you
need to add to one.
Enums and dictionaries
asEnum matches an enum entry by name, case-insensitively. When the wire
spelling differs from Dart's, map it:
json.asEnum(
'status',
values: OrderStatus.values,
wireNames: const {'PARTIALLY_FILLED': OrderStatus.partiallyFilled},
);
// FormatException: Field "status"="CANCELED" is not one of [PARTIALLY_FILLED, newOrder, filled, partiallyFilled].
asMapOf is for the objects an API uses as a dictionary, where the keys are
data and cannot be spelled out in a model. It names the failing entry by key:
json.asMapOf<double>(
'balances',
entry: (raw, field) => asDouble(raw, field: field),
);
// FormatException: Field "balances.BTC"="n/a" cannot be parsed as double.
Handling the failure
Every cast throws a JsonCastException, which is a FormatException — so
catching the latter works as it always did. Catch the former when you want the
parts rather than the sentence:
try {
return Ticker.fromJson(json);
} on JsonCastException catch (e) {
logger.warn('bad ticker', {'field': e.field, 'expected': e.expectedType});
rethrow;
}
When a field genuinely is allowed a default, tryCast keeps that decision at
the call site instead of burying it in the cast:
final fee = tryCast(() => json.asDouble('fee')) ?? 0;
Bounds
asDouble, asInt and asNum take inclusive min and max, for the values
that convert cleanly but cannot be right:
json.asDouble('sharePct', min: 0, max: 100);
// FormatException: Field "sharePct"="150" is outside the range 0..100.
Nested fields
castAt reaches a leaf through nested objects and arrays, and keeps the whole
path in the failure rather than the leaf's own name:
json.castAt('data.orders[0].price', asDouble);
// FormatException: Field "data.orders[0].price"="n/a" cannot be parsed as double.
Any cast fits, including the asNullable… ones. An absent step along the way
resolves to null, so the cast decides whether that is an error; a step that
is present but unwalkable — indexing into a string — throws, naming the part
of the path that did work. valueAt returns the raw value and hasPath is the
path-shaped hasKey.
Prefer unpacking object by object when you are reading many fields out of the
same node; castAt is for the one leaf buried in an envelope.
Absent keys
A missing key reads as null, so it behaves exactly like an explicit null —
the strict casts throw, the nullable ones return null. When a
present-but-null field has to be told apart from an absent one, ask first:
if (json.hasKey('memo')) { /* the server sent the field, possibly as null */ }
Two heuristics worth knowing
asDateTime guesses the epoch unit. A num of 1e12 or more is read as
milliseconds, anything smaller as seconds. That misreads second-precision
timestamps after the year 33658 and millisecond-precision ones before
2001-09-09. When the unit is known, say so and the guess is skipped:
json.asDateTime('updateTime', unit: EpochUnit.seconds);
asString never rejects a non-null value. A number arriving where a string
was promised becomes '12' rather than an error, which is usually what you
want for ids but does mean asString will not catch a type drift on its own.
Reach for asStrictString where that drift is worth hearing about, or
asNonEmptyString where "" is.
License
MIT — see LICENSE.
Libraries
- safe_json_cast
- Typed casts for decoded JSON that name the field they failed on.